推进付费模块运行边界拆分

This commit is contained in:
WuYongJie 2026-06-03 11:37:55 +08:00
parent 504a1d93e3
commit 9f355ded27
25 changed files with 1057 additions and 53 deletions

View File

@ -11,8 +11,12 @@ LICENSE_PROJECT_CODE=flnxi-sucai
# Shared HMAC secret between material app and license server (minimum 32 characters) # Shared HMAC secret between material app and license server (minimum 32 characters)
LICENSE_REQUEST_SIGNING_SECRET=replace-with-shared-license-request-secret LICENSE_REQUEST_SIGNING_SECRET=replace-with-shared-license-request-secret
# Optional paid module service URL. Keep empty unless the module container is running. # Paid module service URLs. Keep the defaults only when module containers run on the same Docker network.
INTRANET_PENETRATION_MODULE_URL= LICENSE_LIMITS_MODULE_URL=http://flnxi-module-license-limits-core:8787
CUSTOM_TEMPLATES_MODULE_URL=http://flnxi-module-custom-templates-core:8787
INTRANET_PENETRATION_MODULE_URL=http://flnxi-module-intranet-penetration-core:8787
# Agent -> app callback token for POST /api/license/premium-modules/runtime-status.
MODULE_RUNTIME_STATUS_TOKEN=replace-with-strong-module-runtime-status-token
# Versioned deployment images # Versioned deployment images
# Customer delivery must pin the full application image tag. Do not rely on latest. # Customer delivery must pin the full application image tag. Do not rely on latest.

View File

@ -132,7 +132,7 @@ LOCAL_ENCRYPTION_KEY=<generate-a-32-char-or-longer-local-key>
授权模块采用“镜像站放模块核心本体,验证服务器发授权清单、实例凭证和短期拉包 token”的分工;免费版的人数/素材数上限也通过 `license-limits-core` 模块下发,付费版再额外下发自定义模板、内网穿透等能力模块。部署 `license-server` 时还要确认 `MODULE_PULL_TOKEN_SECRET`、`PREMIUM_MODULE_REGISTRY_BASE`、模块版本和模块 digest 指向当前可用镜像站。 授权模块采用“镜像站放模块核心本体,验证服务器发授权清单、实例凭证和短期拉包 token”的分工;免费版的人数/素材数上限也通过 `license-limits-core` 模块下发,付费版再额外下发自定义模板、内网穿透等能力模块。部署 `license-server` 时还要确认 `MODULE_PULL_TOKEN_SECRET`、`PREMIUM_MODULE_REGISTRY_BASE`、模块版本和模块 digest 指向当前可用镜像站。
当前已提供第一批模块源码骨架:`premium-modules/license-limits-core`、`premium-modules/custom-templates-core`、`premium-modules/intranet-penetration-core`。模块镜像通过 `deploy-registry.ps1 -Action PublishPremiumModule` 发布,发布后把脚本输出的模块镜像和 digest 写入 `license-server`。其中 `custom-templates-core` 第一版先承接授权和拉包合同,模板业务仍由主应用执行,后续再逐步拆细。 当前已提供第一批模块源码:`premium-modules/license-limits-core`、`premium-modules/custom-templates-core`、`premium-modules/intranet-penetration-core`。模块镜像通过 `deploy-registry.ps1 -Action PublishPremiumModule` 发布,发布后把脚本输出的模块镜像和 digest 写入 `license-server`。主应用现在不仅检查许可证和模块授权凭证,还会要求对应模块处于 `running`、模块 `/health` 正常,并通过模块 `/authorize` 放行后才开放人数/素材限额、自定义模板和内网穿透能力。`custom-templates-core` 当前承接自定义模板授权执行层,模板 CRUD、迁移和数据处理代码后续还要继续迁到模块内部。
### 初始化 Prisma ### 初始化 Prisma

View File

@ -0,0 +1,99 @@
import { NextRequest } from 'next/server';
import { POST } from '../route';
import { updatePremiumModuleRuntimeStatus } from '@/lib/license/premium-module-service';
jest.mock('@/lib/license/premium-module-service', () => ({
PREMIUM_MODULE_IDS: {
LICENSE_LIMITS_CORE: 'license-limits-core',
CUSTOM_TEMPLATES_CORE: 'custom-templates-core',
INTRANET_PENETRATION_CORE: 'intranet-penetration-core',
},
updatePremiumModuleRuntimeStatus: jest.fn(),
}));
const mockUpdatePremiumModuleRuntimeStatus =
updatePremiumModuleRuntimeStatus as jest.MockedFunction<typeof updatePremiumModuleRuntimeStatus>;
function createRequest(body: Record<string, unknown>, token = 'a'.repeat(32)) {
return new NextRequest('http://localhost/api/license/premium-modules/runtime-status', {
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
}
describe('POST /api/license/premium-modules/runtime-status', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.MODULE_RUNTIME_STATUS_TOKEN = 'a'.repeat(32);
delete process.env.FLNXI_MODULE_STATUS_CALLBACK_TOKEN;
});
afterEach(() => {
delete process.env.MODULE_RUNTIME_STATUS_TOKEN;
delete process.env.FLNXI_MODULE_STATUS_CALLBACK_TOKEN;
});
it('需要回写 token,避免公开接口被任意写入模块运行态', async () => {
const response = await POST(createRequest({
moduleId: 'custom-templates-core',
image: 'registry.test/flnxi/premium/custom-templates-core:1.0.0',
status: 'running',
lastSyncAt: new Date().toISOString(),
}, 'wrong-token'));
expect(response.status).toBe(401);
expect(mockUpdatePremiumModuleRuntimeStatus).not.toHaveBeenCalled();
});
it('token 未配置时默认关闭回写入口', async () => {
delete process.env.MODULE_RUNTIME_STATUS_TOKEN;
const response = await POST(createRequest({
moduleId: 'custom-templates-core',
image: 'registry.test/flnxi/premium/custom-templates-core:1.0.0',
status: 'running',
lastSyncAt: new Date().toISOString(),
}));
expect(response.status).toBe(503);
expect(mockUpdatePremiumModuleRuntimeStatus).not.toHaveBeenCalled();
});
it('接受 Agent 上报的合法模块运行状态并持久化', async () => {
const lastSyncAt = new Date().toISOString();
const response = await POST(createRequest({
moduleId: 'custom-templates-core',
image: 'registry.test/flnxi/premium/custom-templates-core:1.0.0',
digest: 'sha256:' + 'a'.repeat(64),
status: 'running',
lastSyncAt,
}));
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ success: true, updated: 1 });
expect(mockUpdatePremiumModuleRuntimeStatus).toHaveBeenCalledWith({
moduleId: 'custom-templates-core',
image: 'registry.test/flnxi/premium/custom-templates-core:1.0.0',
digest: 'sha256:' + 'a'.repeat(64),
status: 'running',
lastSyncAt,
lastError: undefined,
});
});
it('拒绝未知模块或非法状态', async () => {
const response = await POST(createRequest({
moduleId: 'unknown-module',
image: 'registry.test/flnxi/premium/unknown:1.0.0',
status: 'running',
lastSyncAt: new Date().toISOString(),
}));
expect(response.status).toBe(400);
expect(mockUpdatePremiumModuleRuntimeStatus).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from 'next/server';
import {
PREMIUM_MODULE_IDS,
updatePremiumModuleRuntimeStatus,
type PremiumModuleRuntimeStatus,
type PremiumModuleRuntimeStatusValue,
} from '@/lib/license/premium-module-service';
const validModuleIds = new Set<string>(Object.values(PREMIUM_MODULE_IDS));
const validStatuses = new Set<PremiumModuleRuntimeStatusValue>([
'missing',
'pulling',
'running',
'unhealthy',
'expired',
'revoked',
]);
function getRuntimeStatusToken(): string {
return (
process.env.MODULE_RUNTIME_STATUS_TOKEN ||
process.env.FLNXI_MODULE_STATUS_CALLBACK_TOKEN ||
''
).trim();
}
function readBearerToken(request: NextRequest): string {
const authorization = request.headers.get('authorization') || '';
if (authorization.toLowerCase().startsWith('bearer ')) {
return authorization.slice('bearer '.length).trim();
}
return (request.headers.get('x-module-status-token') || '').trim();
}
function normalizeRuntimeStatus(input: unknown): PremiumModuleRuntimeStatus | null {
if (!input || typeof input !== 'object') return null;
const raw = input as Record<string, unknown>;
const moduleId = typeof raw.moduleId === 'string' ? raw.moduleId.trim() : '';
const image = typeof raw.image === 'string' ? raw.image.trim() : '';
const digest = typeof raw.digest === 'string' && raw.digest.trim() ? raw.digest.trim() : null;
const status = typeof raw.status === 'string' ? raw.status.trim() : '';
const lastSyncAt = typeof raw.lastSyncAt === 'string' ? raw.lastSyncAt.trim() : '';
const lastError = typeof raw.lastError === 'string' && raw.lastError.trim()
? raw.lastError.trim()
: undefined;
if (!validModuleIds.has(moduleId)) return null;
if (!image) return null;
if (!validStatuses.has(status as PremiumModuleRuntimeStatusValue)) return null;
const lastSyncAtTime = Date.parse(lastSyncAt);
if (!lastSyncAt || Number.isNaN(lastSyncAtTime)) return null;
return {
moduleId,
image,
digest,
status: status as PremiumModuleRuntimeStatusValue,
lastSyncAt: new Date(lastSyncAtTime).toISOString(),
lastError,
};
}
export async function POST(request: NextRequest) {
const configuredToken = getRuntimeStatusToken();
if (!configuredToken || configuredToken.length < 32) {
return NextResponse.json(
{ success: false, error: '模块运行状态回写 token 未配置' },
{ status: 503 }
);
}
if (readBearerToken(request) !== configuredToken) {
return NextResponse.json(
{ success: false, error: '模块运行状态回写未授权' },
{ status: 401 }
);
}
const body = await request.json().catch(() => null);
const rawStatuses: unknown[] = Array.isArray(body?.statuses) ? body.statuses : [body];
const statuses = rawStatuses.map(normalizeRuntimeStatus);
if (statuses.length === 0 || statuses.some((status: PremiumModuleRuntimeStatus | null) => !status)) {
return NextResponse.json(
{ success: false, error: '模块运行状态参数无效' },
{ status: 400 }
);
}
for (const status of statuses as PremiumModuleRuntimeStatus[]) {
await updatePremiumModuleRuntimeStatus(status);
}
return NextResponse.json({
success: true,
updated: statuses.length,
});
}

View File

@ -135,6 +135,10 @@ services:
- LICENSE_REQUEST_SIGNING_SECRET=${LICENSE_REQUEST_SIGNING_SECRET:?LICENSE_REQUEST_SIGNING_SECRET is required} - LICENSE_REQUEST_SIGNING_SECRET=${LICENSE_REQUEST_SIGNING_SECRET:?LICENSE_REQUEST_SIGNING_SECRET is required}
- LICENSE_PUBLIC_KEY=${LICENSE_PUBLIC_KEY:?LICENSE_PUBLIC_KEY is required} - LICENSE_PUBLIC_KEY=${LICENSE_PUBLIC_KEY:?LICENSE_PUBLIC_KEY is required}
- LOCAL_ENCRYPTION_KEY=${LOCAL_ENCRYPTION_KEY:?LOCAL_ENCRYPTION_KEY is required} - LOCAL_ENCRYPTION_KEY=${LOCAL_ENCRYPTION_KEY:?LOCAL_ENCRYPTION_KEY is required}
- LICENSE_LIMITS_MODULE_URL=${LICENSE_LIMITS_MODULE_URL:-http://flnxi-module-license-limits-core:8787}
- CUSTOM_TEMPLATES_MODULE_URL=${CUSTOM_TEMPLATES_MODULE_URL:-http://flnxi-module-custom-templates-core:8787}
- INTRANET_PENETRATION_MODULE_URL=${INTRANET_PENETRATION_MODULE_URL:-http://flnxi-module-intranet-penetration-core:8787}
- MODULE_RUNTIME_STATUS_TOKEN=${MODULE_RUNTIME_STATUS_TOKEN:-}
# 初始管理员账号(供 /api/system/init 使用) # 初始管理员账号(供 /api/system/init 使用)
- INIT_ADMIN_EMAIL=${INIT_ADMIN_EMAIL:-admin@local.flnxi} - INIT_ADMIN_EMAIL=${INIT_ADMIN_EMAIL:-admin@local.flnxi}
- INIT_ADMIN_PASSWORD=${INIT_ADMIN_PASSWORD:-} - INIT_ADMIN_PASSWORD=${INIT_ADMIN_PASSWORD:-}

View File

@ -74,7 +74,10 @@ LICENSE_PROJECT_CODE=flnxi-sucai
LICENSE_REQUEST_SIGNING_SECRET=<generate-a-shared-license-request-secret> LICENSE_REQUEST_SIGNING_SECRET=<generate-a-shared-license-request-secret>
LICENSE_PUBLIC_KEY=<license-server-rsa-public-key> LICENSE_PUBLIC_KEY=<license-server-rsa-public-key>
LOCAL_ENCRYPTION_KEY=<generate-a-32-char-or-longer-local-key> LOCAL_ENCRYPTION_KEY=<generate-a-32-char-or-longer-local-key>
# INTRANET_PENETRATION_MODULE_URL=http://flnxi-module-intranet-penetration-core:8787 LICENSE_LIMITS_MODULE_URL=http://flnxi-module-license-limits-core:8787
CUSTOM_TEMPLATES_MODULE_URL=http://flnxi-module-custom-templates-core:8787
INTRANET_PENETRATION_MODULE_URL=http://flnxi-module-intranet-penetration-core:8787
MODULE_RUNTIME_STATUS_TOKEN=<generate-a-32-char-or-longer-module-status-token>
MAX_UPLOAD_FILE_SIZE_BYTES=10737418240 MAX_UPLOAD_FILE_SIZE_BYTES=10737418240
MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES=10737418240 MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES=10737418240
MAX_UPLOAD_FIELD_SIZE_BYTES=2097152 MAX_UPLOAD_FIELD_SIZE_BYTES=2097152
@ -91,8 +94,9 @@ MAX_UPLOAD_FIELD_SIZE_BYTES=2097152
- `LICENSE_REQUEST_SIGNING_SECRET` 必须和 `license-server` 的同名变量一致,至少 32 字符,用于主应用到验证服务器的请求签名。 - `LICENSE_REQUEST_SIGNING_SECRET` 必须和 `license-server` 的同名变量一致,至少 32 字符,用于主应用到验证服务器的请求签名。
- `LICENSE_PUBLIC_KEY` 是许可证服务签发许可证时使用的 RSA 公钥,主应用靠它确认许可证没有被伪造。 - `LICENSE_PUBLIC_KEY` 是许可证服务签发许可证时使用的 RSA 公钥,主应用靠它确认许可证没有被伪造。
- `LOCAL_ENCRYPTION_KEY` 用于主应用本地加密保存许可证缓存,至少 32 字符,不能使用示例值。 - `LOCAL_ENCRYPTION_KEY` 用于主应用本地加密保存许可证缓存,至少 32 字符,不能使用示例值。
- `INTRANET_PENETRATION_MODULE_URL` 是可选项;只有客户购买并运行 `intranet-penetration-core` 模块后才填写。未填写时,内网穿透接口仍会明确返回“真实提供商未接入”。 - `LICENSE_LIMITS_MODULE_URL`、`CUSTOM_TEMPLATES_MODULE_URL`、`INTRANET_PENETRATION_MODULE_URL` 指向同一 Docker 网络里的模块容器。模块没运行或健康检查失败时,对应人数/素材限额、自定义模板、内网穿透能力都会被服务端拒绝。
- 授权模块核心本体走镜像站,不走验证服务器大文件下载;`license-server` 需要配置 `MODULE_PULL_TOKEN_SECRET`、`PREMIUM_MODULE_REGISTRY_BASE` 和 `PREMIUM_MODULE_VERSION`,用于给客户实例下发签名模块清单、授权凭证和短期拉包 token。免费版人数/素材数上限也通过 `license-limits-core` 模块下发。 - `MODULE_RUNTIME_STATUS_TOKEN` 用于 Agent 回写模块运行状态;Agent 侧 `FLNXI_MODULE_STATUS_CALLBACK_TOKEN` 必须和它一致,回调地址通常是 `http://<主应用地址>/api/license/premium-modules/runtime-status`。
- 授权模块核心本体走镜像站,不走验证服务器大文件下载;`license-server` 需要配置 `MODULE_PULL_TOKEN_SECRET`、`PREMIUM_MODULE_REGISTRY_BASE` 和 `PREMIUM_MODULE_VERSION`,用于给客户实例下发签名模块清单、授权凭证和短期拉包 token。免费版人数/素材数上限也通过 `license-limits-core` 模块下发,并且需要模块运行态可用。
- `MAX_UPLOAD_FILE_SIZE_BYTES` 是单个素材文件上限;`MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES` 是一次上传请求总上限;`MAX_UPLOAD_FIELD_SIZE_BYTES` 是表单文字字段总上限。示例值分别是 10GB、10GB、2MB,低配 NAS 可按客户机器内存和磁盘调小。 - `MAX_UPLOAD_FILE_SIZE_BYTES` 是单个素材文件上限;`MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES` 是一次上传请求总上限;`MAX_UPLOAD_FIELD_SIZE_BYTES` 是表单文字字段总上限。示例值分别是 10GB、10GB、2MB,低配 NAS 可按客户机器内存和磁盘调小。
- 不要把真实密码、许可证地址、对象存储密钥写入 Git。 - 不要把真实密码、许可证地址、对象存储密钥写入 Git。

View File

@ -153,9 +153,9 @@ ${LICENSE_SERVER_URL}/api/license/${LICENSE_PROJECT_CODE}/...
镜像站推荐由 Nginx / Registry 代理做私有拉取鉴权:发布账号仍只用于内部推送镜像;客户侧拉付费模块时使用用户名 `flnxi-module`,密码使用验证系统签发的短期 `pullToken`。代理把 Registry 主机、仓库、请求类型、digest 和 token 转发给 `GET /api/registry/auth` 校验,通过后才放行拉取。Registry 原始后端不要直接对客户开放。 镜像站推荐由 Nginx / Registry 代理做私有拉取鉴权:发布账号仍只用于内部推送镜像;客户侧拉付费模块时使用用户名 `flnxi-module`,密码使用验证系统签发的短期 `pullToken`。代理把 Registry 主机、仓库、请求类型、digest 和 token 转发给 `GET /api/registry/auth` 校验,通过后才放行拉取。Registry 原始后端不要直接对客户开放。
主应用不会直接挂 Docker Socket,也不会自己启动模块容器。当前 Go Agent 已提供最小 `POST /api/modules/sync` 和 `GET /api/modules/status` 入口,用来接收主应用准备好的短期拉取凭证,负责拉取模块镜像、校验 digest、启动模块容器、健康检查和失败回滚。 主应用不会直接挂 Docker Socket,也不会自己启动模块容器。当前 Go Agent 已提供 `POST /api/modules/sync` 和 `GET /api/modules/status` 入口,用来接收主应用准备好的短期拉取凭证,负责拉取模块镜像、校验 digest、启动模块容器、健康检查和失败回滚。模块运行状态变化后,Agent 可通过 `POST /api/license/premium-modules/runtime-status` 回写主应用;主应用会把回写状态持久化,并把运行态纳入付费能力判断。
第一批模块骨架已经放在: 第一批模块源码已经放在:
```text ```text
premium-modules/license-limits-core premium-modules/license-limits-core
@ -163,7 +163,7 @@ premium-modules/custom-templates-core
premium-modules/intranet-penetration-core premium-modules/intranet-penetration-core
``` ```
`license-limits-core` 承接免费版和付费版的人数/素材数上限合同;`custom-templates-core` 第一版先承接授权和拉包合同,模板业务仍由主应用执行;主应用内网穿透入口通过许可证检查后,会优先调用 `INTRANET_PENETRATION_MODULE_URL` 指向的模块服务。未配置该变量时,仍保持“真实提供商未接入”的拒绝返回,不会伪装启动成功。 `license-limits-core` 承接免费版和付费版的人数/素材数上限门禁;`custom-templates-core` 承接自定义模板授权执行层;`intranet-penetration-core` 承接内网穿透模块门禁。主应用现在会同时检查模块授权凭证、Agent 回写的 `running` 状态、模块 `/health` 和模块 `/authorize`,任一环节不可用都会拒绝对应付费能力。自定义模板的 CRUD、迁移和复杂数据处理后续还需要继续从主应用迁到 `custom-templates-core` 内部。
## 6. 后台管理入口 ## 6. 后台管理入口

View File

@ -65,7 +65,9 @@ docker run -d \
| `FLNXI_POSTGRES_IMAGE` | 安装时使用的 PostgreSQL 镜像;也兼容读取 `POSTGRES_IMAGE` | `postgres:15-alpine` | | `FLNXI_POSTGRES_IMAGE` | 安装时使用的 PostgreSQL 镜像;也兼容读取 `POSTGRES_IMAGE` | `postgres:15-alpine` |
| `FLNXI_MINIO_IMAGE` | 安装时使用的 MinIO 镜像;也兼容读取 `MINIO_IMAGE` | `minio/minio:RELEASE.2025-04-22T22-12-26Z` | | `FLNXI_MINIO_IMAGE` | 安装时使用的 MinIO 镜像;也兼容读取 `MINIO_IMAGE` | `minio/minio:RELEASE.2025-04-22T22-12-26Z` |
| `FLNXI_VERSION_LIST_URL` | Agent 管理界面可选版本列表来源,返回 Registry tags 结构 | 未设置 | | `FLNXI_VERSION_LIST_URL` | Agent 管理界面可选版本列表来源,返回 Registry tags 结构 | 未设置 |
| `FLNXI_MODULE_NETWORK` | 付费模块容器默认加入的 Docker 网络 | `flnxi-app-network` | | `FLNXI_MODULE_NETWORK` | 付费模块容器默认加入的 Docker 网络 | `flnxi-network` |
| `FLNXI_MODULE_STATUS_CALLBACK_URL` | 付费模块运行状态变化时回调主应用的接口地址;未配置时不回调 | 未设置 |
| `FLNXI_MODULE_STATUS_CALLBACK_TOKEN` | 付费模块状态回调使用的 Bearer token;需和回调地址同时配置,不会写入日志 | 未设置 |
| `LICENSE_SERVER_URL` | 主应用许可证服务地址,安装素材系统时会写入主应用容器 | 未设置 | | `LICENSE_SERVER_URL` | 主应用许可证服务地址,安装素材系统时会写入主应用容器 | 未设置 |
| `LICENSE_PROJECT_CODE` | 主应用许可证项目编码,安装素材系统时会写入主应用容器 | 未设置 | | `LICENSE_PROJECT_CODE` | 主应用许可证项目编码,安装素材系统时会写入主应用容器 | 未设置 |
| `LICENSE_REQUEST_SIGNING_SECRET` | 主应用请求许可证服务的签名密钥,至少 32 字符 | 未设置 | | `LICENSE_REQUEST_SIGNING_SECRET` | 主应用请求许可证服务的签名密钥,至少 32 字符 | 未设置 |
@ -105,7 +107,22 @@ GET /api/modules/status
} }
``` ```
Agent 会拒绝过期 token、镜像地址不匹配、digest 不匹配或缺少固定版本 tag 的请求。模块容器默认加入 `flnxi-app-network`,也可以用 `FLNXI_MODULE_NETWORK` 或请求里的 `network` 覆盖;默认容器名为 `flnxi-module-{moduleId}`。如健康检查失败,会尝试恢复旧模块容器。 Agent 会拒绝过期 token、镜像地址不匹配、digest 不匹配或缺少固定版本 tag 的请求。模块容器默认加入 `flnxi-network`,也可以用 `FLNXI_MODULE_NETWORK` 或请求里的 `network` 覆盖;默认容器名为 `flnxi-module-{moduleId}`。如健康检查失败,会尝试恢复旧模块容器。
模块状态变化时,Agent 会先更新本地 `GET /api/modules/status` 可见的状态。如果同时配置了 `FLNXI_MODULE_STATUS_CALLBACK_URL` 和 `FLNXI_MODULE_STATUS_CALLBACK_TOKEN`,Agent 会向主应用回调地址发送 `POST` 请求,并在请求头里带上 `Authorization: Bearer <token>`。通过 Agent 安装主应用时,如果没有显式配置回调地址,安装流程会为本次安装生成回写 token,并按主应用访问地址生成默认回调地址。请求体为:
```json
{
"moduleId": "intranet-penetration-core",
"image": "registry.example.com/flnxi/premium/intranet-penetration-core:1.0.0",
"digest": "sha256:...",
"status": "running",
"lastSyncAt": "2026-06-02T00:20:00Z",
"lastError": ""
}
```
未同时配置回调地址和 token 时,Agent 保持现有行为,只在本地保存模块状态,不主动通知主应用。回调失败不会改变模块本地运行状态,主应用仍可通过 `GET /api/modules/status` 主动查询。
安装素材系统前,至少要给 Agent 容器配置: 安装素材系统前,至少要给 Agent 容器配置:

View File

@ -21,6 +21,12 @@ describe('flnxi-agent-go/install.sh', () => {
expect(script).toContain('未完整配置许可证服务参数') expect(script).toContain('未完整配置许可证服务参数')
}) })
it('会把付费模块运行网络和状态回写配置透传给 Agent 容器', () => {
expect(script).toContain('FLNXI_MODULE_NETWORK=${FLNXI_MODULE_NETWORK:-flnxi-network}')
expect(script).toContain('FLNXI_MODULE_STATUS_CALLBACK_URL=${FLNXI_MODULE_STATUS_CALLBACK_URL:-}')
expect(script).toContain('FLNXI_MODULE_STATUS_CALLBACK_TOKEN=${FLNXI_MODULE_STATUS_CALLBACK_TOKEN:-}')
})
it('文案明确这是 Agent 安装脚本,不冒充客户最终首装入口', () => { it('文案明确这是 Agent 安装脚本,不冒充客户最终首装入口', () => {
expect(script).toContain('FLNXI Agent 安装脚本') expect(script).toContain('FLNXI Agent 安装脚本')
expect(script).toContain('Agent 安装脚本') expect(script).toContain('Agent 安装脚本')

View File

@ -233,6 +233,9 @@ run_agent() {
-e "LICENSE_REQUEST_SIGNING_SECRET=${LICENSE_REQUEST_SIGNING_SECRET:-}" -e "LICENSE_REQUEST_SIGNING_SECRET=${LICENSE_REQUEST_SIGNING_SECRET:-}"
-e "LICENSE_PUBLIC_KEY=${LICENSE_PUBLIC_KEY:-}" -e "LICENSE_PUBLIC_KEY=${LICENSE_PUBLIC_KEY:-}"
-e "LOCAL_ENCRYPTION_KEY=${LOCAL_ENCRYPTION_KEY:-}" -e "LOCAL_ENCRYPTION_KEY=${LOCAL_ENCRYPTION_KEY:-}"
-e "FLNXI_MODULE_NETWORK=${FLNXI_MODULE_NETWORK:-flnxi-network}"
-e "FLNXI_MODULE_STATUS_CALLBACK_URL=${FLNXI_MODULE_STATUS_CALLBACK_URL:-}"
-e "FLNXI_MODULE_STATUS_CALLBACK_TOKEN=${FLNXI_MODULE_STATUS_CALLBACK_TOKEN:-}"
) )
if [ -z "${FLNXI_MATERIAL_IMAGE:-${APP_IMAGE:-}}" ]; then if [ -z "${FLNXI_MATERIAL_IMAGE:-${APP_IMAGE:-}}" ]; then

View File

@ -1,6 +1,7 @@
package api package api
import ( import (
"bytes"
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
@ -161,7 +162,12 @@ func resolveInstallLicenseConfig(req InstallRequest, secrets InstallSecrets) (In
return config, nil return config, nil
} }
func resolveModuleStatusToken(secrets InstallSecrets) string {
return firstNonEmpty(os.Getenv(envModuleStatusCallbackToken), secrets.ModuleStatusToken)
}
func materialAppEnv(req InstallRequest, secrets InstallSecrets, licenseConfig InstallLicenseConfig, localIP string, webPort int) []string { func materialAppEnv(req InstallRequest, secrets InstallSecrets, licenseConfig InstallLicenseConfig, localIP string, webPort int) []string {
moduleStatusToken := resolveModuleStatusToken(secrets)
return []string{ return []string{
"LICENSE_KEY=" + req.LicenseKey, "LICENSE_KEY=" + req.LicenseKey,
"LICENSE_SERVER_URL=" + licenseConfig.ServerURL, "LICENSE_SERVER_URL=" + licenseConfig.ServerURL,
@ -185,6 +191,10 @@ func materialAppEnv(req InstallRequest, secrets InstallSecrets, licenseConfig In
"NEXTAUTH_SECRET=" + secrets.NextAuthSecret, "NEXTAUTH_SECRET=" + secrets.NextAuthSecret,
"NEXTAUTH_URL=http://" + localIP + ":" + fmt.Sprintf("%d", webPort), "NEXTAUTH_URL=http://" + localIP + ":" + fmt.Sprintf("%d", webPort),
"APP_PUBLIC_URL=http://" + localIP + ":" + fmt.Sprintf("%d", webPort), "APP_PUBLIC_URL=http://" + localIP + ":" + fmt.Sprintf("%d", webPort),
"LICENSE_LIMITS_MODULE_URL=http://flnxi-module-license-limits-core:8787",
"CUSTOM_TEMPLATES_MODULE_URL=http://flnxi-module-custom-templates-core:8787",
"INTRANET_PENETRATION_MODULE_URL=http://flnxi-module-intranet-penetration-core:8787",
"MODULE_RUNTIME_STATUS_TOKEN=" + moduleStatusToken,
} }
} }
@ -256,6 +266,8 @@ const (
envLicenseRequestSigningSecret = "LICENSE_REQUEST_SIGNING_SECRET" envLicenseRequestSigningSecret = "LICENSE_REQUEST_SIGNING_SECRET"
envLicensePublicKey = "LICENSE_PUBLIC_KEY" envLicensePublicKey = "LICENSE_PUBLIC_KEY"
envLocalEncryptionKey = "LOCAL_ENCRYPTION_KEY" envLocalEncryptionKey = "LOCAL_ENCRYPTION_KEY"
envModuleStatusCallbackURL = "FLNXI_MODULE_STATUS_CALLBACK_URL"
envModuleStatusCallbackToken = "FLNXI_MODULE_STATUS_CALLBACK_TOKEN"
) )
var ( var (
@ -267,6 +279,9 @@ var (
moduleStatusesMutex sync.RWMutex moduleStatusesMutex sync.RWMutex
moduleSyncLocks = map[string]bool{} moduleSyncLocks = map[string]bool{}
moduleSyncLocksMutex sync.Mutex moduleSyncLocksMutex sync.Mutex
moduleCallbackURL string
moduleCallbackToken string
moduleCallbackMutex sync.RWMutex
) )
type InstallStatus struct { type InstallStatus struct {
@ -545,6 +560,11 @@ func runInstall(dc installDockerClient, req InstallRequest) {
// 获取宿主机 IP 用于配置公开访问地址(从用户请求中获取,确保是宿主机 IP 而非容器 IP) // 获取宿主机 IP 用于配置公开访问地址(从用户请求中获取,确保是宿主机 IP 而非容器 IP)
localIP := getHostIP() localIP := getHostIP()
moduleStatusCallbackURL := firstNonEmpty(
os.Getenv(envModuleStatusCallbackURL),
"http://"+localIP+":"+fmt.Sprintf("%d", webPort)+"/api/license/premium-modules/runtime-status",
)
setModuleStatusCallbackConfig(moduleStatusCallbackURL, resolveModuleStatusToken(secrets))
// 启动素材系统 // 启动素材系统
setStep("启动服务", 90, "启动素材系统...") setStep("启动服务", 90, "启动素材系统...")
@ -651,6 +671,56 @@ func setModuleStatus(status ModuleRuntimeStatus) {
moduleStatusesMutex.Lock() moduleStatusesMutex.Lock()
moduleStatuses[status.ModuleID] = status moduleStatuses[status.ModuleID] = status
moduleStatusesMutex.Unlock() moduleStatusesMutex.Unlock()
go reportModuleStatus(status)
}
func setModuleStatusCallbackConfig(callbackURL string, callbackToken string) {
moduleCallbackMutex.Lock()
moduleCallbackURL = strings.TrimSpace(callbackURL)
moduleCallbackToken = strings.TrimSpace(callbackToken)
moduleCallbackMutex.Unlock()
}
func getModuleStatusCallbackConfig() (string, string) {
callbackURL := strings.TrimSpace(os.Getenv(envModuleStatusCallbackURL))
callbackToken := strings.TrimSpace(os.Getenv(envModuleStatusCallbackToken))
if callbackURL != "" && callbackToken != "" {
return callbackURL, callbackToken
}
moduleCallbackMutex.RLock()
defer moduleCallbackMutex.RUnlock()
return moduleCallbackURL, moduleCallbackToken
}
func reportModuleStatus(status ModuleRuntimeStatus) {
callbackURL, callbackToken := getModuleStatusCallbackConfig()
if callbackURL == "" || callbackToken == "" {
return
}
body, err := json.Marshal(status)
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, callbackURL, bytes.NewReader(body))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+callbackToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
} }
func tryBeginModuleSync(syncKey string) bool { func tryBeginModuleSync(syncKey string) bool {
@ -826,7 +896,7 @@ func repoDigestsContain(repoDigests []string, digest string) bool {
} }
func moduleContainerConfig(req ModuleSyncRequest) (*container.Config, *container.HostConfig) { func moduleContainerConfig(req ModuleSyncRequest) (*container.Config, *container.HostConfig) {
network := firstNonEmpty(req.Network, os.Getenv("FLNXI_MODULE_NETWORK"), "flnxi-app-network") network := firstNonEmpty(req.Network, os.Getenv("FLNXI_MODULE_NETWORK"), "flnxi-network")
return &container.Config{ return &container.Config{
Image: req.Image, Image: req.Image,
Env: moduleEnv(req), Env: moduleEnv(req),

View File

@ -2,7 +2,10 @@ package api
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"net/http"
"net/http/httptest"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -115,6 +118,7 @@ func TestMaterialAppEnvIncludesLicenseRuntimeConfig(t *testing.T) {
S3Bucket: "materials", S3Bucket: "materials",
NextAuthSecret: "next-secret", NextAuthSecret: "next-secret",
LocalEncryptionKey: strings.Repeat("b", 32), LocalEncryptionKey: strings.Repeat("b", 32),
ModuleStatusToken: strings.Repeat("c", 32),
} }
licenseConfig := InstallLicenseConfig{ licenseConfig := InstallLicenseConfig{
ServerURL: "https://license.example.test", ServerURL: "https://license.example.test",
@ -135,6 +139,10 @@ func TestMaterialAppEnvIncludesLicenseRuntimeConfig(t *testing.T) {
"S3_PUBLIC_DOMAIN", "S3_PUBLIC_DOMAIN",
"NEXTAUTH_URL", "NEXTAUTH_URL",
"APP_PUBLIC_URL", "APP_PUBLIC_URL",
"LICENSE_LIMITS_MODULE_URL",
"CUSTOM_TEMPLATES_MODULE_URL",
"INTRANET_PENETRATION_MODULE_URL",
"MODULE_RUNTIME_STATUS_TOKEN",
} { } {
if env[want] == "" { if env[want] == "" {
t.Fatalf("materialAppEnv() missing %s in %v", want, env) t.Fatalf("materialAppEnv() missing %s in %v", want, env)
@ -143,6 +151,9 @@ func TestMaterialAppEnvIncludesLicenseRuntimeConfig(t *testing.T) {
if env["APP_PUBLIC_URL"] != "http://192.168.1.20:4320" { if env["APP_PUBLIC_URL"] != "http://192.168.1.20:4320" {
t.Fatalf("APP_PUBLIC_URL = %q, want host URL", env["APP_PUBLIC_URL"]) t.Fatalf("APP_PUBLIC_URL = %q, want host URL", env["APP_PUBLIC_URL"])
} }
if env["MODULE_RUNTIME_STATUS_TOKEN"] != strings.Repeat("c", 32) {
t.Fatalf("MODULE_RUNTIME_STATUS_TOKEN not propagated to app")
}
} }
func TestRunInstallStopsBeforePullWhenLicenseConfigMissing(t *testing.T) { func TestRunInstallStopsBeforePullWhenLicenseConfigMissing(t *testing.T) {
@ -363,6 +374,116 @@ func TestRunModuleSyncRestoresOldContainerAfterHealthFailure(t *testing.T) {
} }
} }
func TestSetModuleStatusSkipsCallbackWhenConfigMissing(t *testing.T) {
resetModuleStatusesForTest()
setModuleStatusCallbackConfig("", "")
t.Setenv(envModuleStatusCallbackURL, "")
t.Setenv(envModuleStatusCallbackToken, "callback-token")
called := make(chan struct{}, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called <- struct{}{}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
setModuleStatus(moduleStatus(validModuleSyncRequest(), "running", nil))
select {
case <-called:
t.Fatal("module status callback should not be sent without callback URL")
case <-time.After(100 * time.Millisecond):
}
}
func TestSetModuleStatusReportsCallbackWhenConfigured(t *testing.T) {
resetModuleStatusesForTest()
setModuleStatusCallbackConfig("", "")
received := make(chan struct {
auth string
status ModuleRuntimeStatus
}, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var status ModuleRuntimeStatus
if err := json.NewDecoder(r.Body).Decode(&status); err != nil {
t.Errorf("decode callback body: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
received <- struct {
auth string
status ModuleRuntimeStatus
}{
auth: r.Header.Get("Authorization"),
status: status,
}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
t.Setenv(envModuleStatusCallbackURL, server.URL)
t.Setenv(envModuleStatusCallbackToken, "callback-token")
req := validModuleSyncRequest()
setModuleStatus(moduleStatus(req, "unhealthy", errors.New("not healthy")))
select {
case got := <-received:
if got.auth != "Bearer callback-token" {
t.Fatalf("Authorization = %q, want bearer callback token", got.auth)
}
if got.status.ModuleID != req.ModuleID {
t.Fatalf("moduleId = %q, want %q", got.status.ModuleID, req.ModuleID)
}
if got.status.Image != req.Image {
t.Fatalf("image = %q, want %q", got.status.Image, req.Image)
}
if got.status.Digest != req.Digest {
t.Fatalf("digest = %q, want %q", got.status.Digest, req.Digest)
}
if got.status.Status != "unhealthy" {
t.Fatalf("status = %q, want unhealthy", got.status.Status)
}
if got.status.LastSyncAt == "" {
t.Fatal("lastSyncAt should be set")
}
if !strings.Contains(got.status.LastError, "not healthy") {
t.Fatalf("lastError = %q, want health error", got.status.LastError)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for module status callback")
}
}
func TestSetModuleStatusReportsInstallFallbackCallback(t *testing.T) {
resetModuleStatusesForTest()
received := make(chan string, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
received <- r.Header.Get("Authorization")
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
t.Setenv(envModuleStatusCallbackURL, "")
t.Setenv(envModuleStatusCallbackToken, "")
setModuleStatusCallbackConfig(server.URL, "fallback-token")
setModuleStatus(moduleStatus(validModuleSyncRequest(), "running", nil))
select {
case got := <-received:
if got != "Bearer fallback-token" {
t.Fatalf("Authorization = %q, want fallback bearer token", got)
}
case <-time.After(time.Second):
t.Fatal("module status callback not received")
}
}
const testModuleDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" const testModuleDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
func validModuleSyncRequest() ModuleSyncRequest { func validModuleSyncRequest() ModuleSyncRequest {
@ -402,6 +523,8 @@ func resetModuleStatusesForTest() {
moduleSyncLocksMutex.Lock() moduleSyncLocksMutex.Lock()
moduleSyncLocks = map[string]bool{} moduleSyncLocks = map[string]bool{}
moduleSyncLocksMutex.Unlock() moduleSyncLocksMutex.Unlock()
setModuleStatusCallbackConfig("", "")
} }
func envSliceToMap(values []string) map[string]string { func envSliceToMap(values []string) map[string]string {

View File

@ -15,6 +15,7 @@ type InstallSecrets struct {
S3Bucket string S3Bucket string
NextAuthSecret string NextAuthSecret string
LocalEncryptionKey string LocalEncryptionKey string
ModuleStatusToken string
} }
func generateInstallSecrets() (InstallSecrets, error) { func generateInstallSecrets() (InstallSecrets, error) {
@ -38,6 +39,11 @@ func generateInstallSecrets() (InstallSecrets, error) {
return InstallSecrets{}, err return InstallSecrets{}, err
} }
moduleStatusToken, err := randomHex(32)
if err != nil {
return InstallSecrets{}, err
}
return InstallSecrets{ return InstallSecrets{
DbUser: "flnxi", DbUser: "flnxi",
DbPassword: dbPassword, DbPassword: dbPassword,
@ -47,6 +53,7 @@ func generateInstallSecrets() (InstallSecrets, error) {
S3Bucket: "flnxi", S3Bucket: "flnxi",
NextAuthSecret: nextAuthSecret, NextAuthSecret: nextAuthSecret,
LocalEncryptionKey: localEncryptionKey, LocalEncryptionKey: localEncryptionKey,
ModuleStatusToken: moduleStatusToken,
}, nil }, nil
} }

View File

@ -20,8 +20,8 @@ jest.mock('../license/license-service', () => ({
})); }));
jest.mock('../license/premium-module-service', () => ({ jest.mock('../license/premium-module-service', () => ({
authorizePremiumModuleAction: jest.fn(),
getAuthorizedUsageLimits: jest.fn(), getAuthorizedUsageLimits: jest.fn(),
hasPremiumModule: jest.fn(),
PREMIUM_MODULE_IDS: { PREMIUM_MODULE_IDS: {
LICENSE_LIMITS_CORE: 'license-limits-core', LICENSE_LIMITS_CORE: 'license-limits-core',
CUSTOM_TEMPLATES_CORE: 'custom-templates-core', CUSTOM_TEMPLATES_CORE: 'custom-templates-core',
@ -38,18 +38,28 @@ jest.mock('@/lib/prisma', () => ({
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { isFeatureEnabled, getLicenseType } from '../license/license-service'; import { isFeatureEnabled, getLicenseType } from '../license/license-service';
import { getAuthorizedUsageLimits, hasPremiumModule } from '../license/premium-module-service'; import {
authorizePremiumModuleAction,
getAuthorizedUsageLimits,
} from '../license/premium-module-service';
const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>; const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>;
const mockGetLicenseType = getLicenseType as jest.MockedFunction<typeof getLicenseType>; const mockGetLicenseType = getLicenseType as jest.MockedFunction<typeof getLicenseType>;
const mockGetAuthorizedUsageLimits = getAuthorizedUsageLimits as jest.MockedFunction<typeof getAuthorizedUsageLimits>; const mockGetAuthorizedUsageLimits = getAuthorizedUsageLimits as jest.MockedFunction<typeof getAuthorizedUsageLimits>;
const mockHasPremiumModule = hasPremiumModule as jest.MockedFunction<typeof hasPremiumModule>; const mockAuthorizePremiumModuleAction =
authorizePremiumModuleAction as jest.MockedFunction<typeof authorizePremiumModuleAction>;
const mockUserCount = prisma.user.count as jest.MockedFunction<typeof prisma.user.count>; const mockUserCount = prisma.user.count as jest.MockedFunction<typeof prisma.user.count>;
const mockMaterialCount = prisma.material.count as jest.MockedFunction<typeof prisma.material.count>; const mockMaterialCount = prisma.material.count as jest.MockedFunction<typeof prisma.material.count>;
describe('canCreateUser', () => { describe('canCreateUser', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
mockAuthorizePremiumModuleAction.mockResolvedValue({
available: true,
moduleId: 'license-limits-core',
action: 'licenseLimits:enforce',
status: 'running',
});
}); });
it('当未达到限制时应该返回 allowed: true', async () => { it('当未达到限制时应该返回 allowed: true', async () => {
@ -109,6 +119,12 @@ describe('canCreateUser', () => {
describe('canUploadMaterial', () => { describe('canUploadMaterial', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
mockAuthorizePremiumModuleAction.mockResolvedValue({
available: true,
moduleId: 'license-limits-core',
action: 'licenseLimits:enforce',
status: 'running',
});
}); });
it('当未达到限制时应该返回 allowed: true', async () => { it('当未达到限制时应该返回 allowed: true', async () => {
@ -171,21 +187,34 @@ describe('功能开关检查', () => {
describe('canUseCustomTemplates', () => { describe('canUseCustomTemplates', () => {
it('当功能启用时应该返回 true', async () => { it('当功能启用时应该返回 true', async () => {
mockIsFeatureEnabled.mockResolvedValue(true); mockIsFeatureEnabled.mockResolvedValue(true);
mockHasPremiumModule.mockResolvedValue(true); mockAuthorizePremiumModuleAction.mockResolvedValue({
available: true,
moduleId: 'custom-templates-core',
action: 'customTemplates:use',
status: 'running',
});
expect(await canUseCustomTemplates()).toBe(true); expect(await canUseCustomTemplates()).toBe(true);
expect(mockIsFeatureEnabled).toHaveBeenCalledWith('customTemplates'); expect(mockIsFeatureEnabled).toHaveBeenCalledWith('customTemplates');
expect(mockHasPremiumModule).toHaveBeenCalledWith('custom-templates-core'); expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith(
'custom-templates-core',
'customTemplates:use'
);
}); });
it('当功能禁用时应该返回 false', async () => { it('当功能禁用时应该返回 false', async () => {
mockIsFeatureEnabled.mockResolvedValue(false); mockIsFeatureEnabled.mockResolvedValue(false);
expect(await canUseCustomTemplates()).toBe(false); expect(await canUseCustomTemplates()).toBe(false);
expect(mockHasPremiumModule).not.toHaveBeenCalled(); expect(mockAuthorizePremiumModuleAction).not.toHaveBeenCalled();
}); });
it('当缺少付费能力模块凭证时应该返回 false', async () => { it('当付费能力模块未运行或拒绝授权时应该返回 false', async () => {
mockIsFeatureEnabled.mockResolvedValue(true); mockIsFeatureEnabled.mockResolvedValue(true);
mockHasPremiumModule.mockResolvedValue(false); mockAuthorizePremiumModuleAction.mockResolvedValue({
available: false,
moduleId: 'custom-templates-core',
action: 'customTemplates:use',
status: 'missing',
});
expect(await canUseCustomTemplates()).toBe(false); expect(await canUseCustomTemplates()).toBe(false);
}); });
}); });
@ -206,21 +235,34 @@ describe('功能开关检查', () => {
describe('canUseIntranetPenetration', () => { describe('canUseIntranetPenetration', () => {
it('当功能启用时应该返回 true', async () => { it('当功能启用时应该返回 true', async () => {
mockIsFeatureEnabled.mockResolvedValue(true); mockIsFeatureEnabled.mockResolvedValue(true);
mockHasPremiumModule.mockResolvedValue(true); mockAuthorizePremiumModuleAction.mockResolvedValue({
available: true,
moduleId: 'intranet-penetration-core',
action: 'intranetPenetration:use',
status: 'running',
});
expect(await canUseIntranetPenetration()).toBe(true); expect(await canUseIntranetPenetration()).toBe(true);
expect(mockIsFeatureEnabled).toHaveBeenCalledWith('intranetPenetration'); expect(mockIsFeatureEnabled).toHaveBeenCalledWith('intranetPenetration');
expect(mockHasPremiumModule).toHaveBeenCalledWith('intranet-penetration-core'); expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith(
'intranet-penetration-core',
'intranetPenetration:use'
);
}); });
it('当功能禁用时应该返回 false', async () => { it('当功能禁用时应该返回 false', async () => {
mockIsFeatureEnabled.mockResolvedValue(false); mockIsFeatureEnabled.mockResolvedValue(false);
expect(await canUseIntranetPenetration()).toBe(false); expect(await canUseIntranetPenetration()).toBe(false);
expect(mockHasPremiumModule).not.toHaveBeenCalled(); expect(mockAuthorizePremiumModuleAction).not.toHaveBeenCalled();
}); });
it('当缺少付费能力模块凭证时应该返回 false', async () => { it('当付费能力模块未运行或拒绝授权时应该返回 false', async () => {
mockIsFeatureEnabled.mockResolvedValue(true); mockIsFeatureEnabled.mockResolvedValue(true);
mockHasPremiumModule.mockResolvedValue(false); mockAuthorizePremiumModuleAction.mockResolvedValue({
available: false,
moduleId: 'intranet-penetration-core',
action: 'intranetPenetration:use',
status: 'unhealthy',
});
expect(await canUseIntranetPenetration()).toBe(false); expect(await canUseIntranetPenetration()).toBe(false);
}); });
}); });

View File

@ -112,7 +112,7 @@ describe('许可证业务入口覆盖', () => {
it('高价值付费能力必须同时检查签名模块凭证', () => { it('高价值付费能力必须同时检查签名模块凭证', () => {
const source = readProjectFile('lib/license/check-limits.ts'); const source = readProjectFile('lib/license/check-limits.ts');
expect(source).toContain('hasPremiumModule'); expect(source).toContain('authorizePremiumModuleAction');
expect(source).toContain('PREMIUM_MODULE_IDS.CUSTOM_TEMPLATES_CORE'); expect(source).toContain('PREMIUM_MODULE_IDS.CUSTOM_TEMPLATES_CORE');
expect(source).toContain('PREMIUM_MODULE_IDS.INTRANET_PENETRATION_CORE'); expect(source).toContain('PREMIUM_MODULE_IDS.INTRANET_PENETRATION_CORE');
}); });

View File

@ -156,6 +156,9 @@ describe('premium-module-service', () => {
jest.clearAllMocks(); jest.clearAllMocks();
mockFetch.mockReset(); mockFetch.mockReset();
mockVerifySignature.mockReturnValue(true); mockVerifySignature.mockReturnValue(true);
delete process.env.LICENSE_LIMITS_MODULE_URL;
delete process.env.CUSTOM_TEMPLATES_MODULE_URL;
delete process.env.INTRANET_PENETRATION_MODULE_URL;
}); });
it('免费版也应同步限额授权模块', async () => { it('免费版也应同步限额授权模块', async () => {
@ -452,8 +455,32 @@ describe('premium-module-service', () => {
}); });
} }
if (where.key === 'premium_module_runtime_status') {
return Promise.resolve({
value: `encrypted:${JSON.stringify({
'license-limits-core': {
moduleId: 'license-limits-core',
image: limitsModuleArtifact.image,
digest: null,
status: 'running',
lastSyncAt: new Date().toISOString(),
},
})}`,
});
}
return Promise.resolve(null); return Promise.resolve(null);
}); });
process.env.LICENSE_LIMITS_MODULE_URL = 'http://license-limits-module.test';
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ moduleId: 'license-limits-core', status: 'healthy' }),
}).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ moduleId: 'license-limits-core', allowed: true }),
});
const { getAuthorizedUsageLimits } = await import('../license/premium-module-service'); const { getAuthorizedUsageLimits } = await import('../license/premium-module-service');
@ -491,6 +518,66 @@ describe('premium-module-service', () => {
await expect(getAuthorizedUsageLimits()).resolves.toBeNull(); await expect(getAuthorizedUsageLimits()).resolves.toBeNull();
}); });
it('限额模块未运行时即使授权凭证有效也不应返回限额', async () => {
mockFreeLicense();
(prisma.systemConfig.findUnique as jest.Mock).mockImplementation(({ where }) => {
if (where.key === 'premium_module_manifest') {
return Promise.resolve({
value: `encrypted:${JSON.stringify({
manifest: {
version: 1,
licenseType: 'FREE',
instanceId: 'instance-1',
modules: [mockLimitsManifestModule()],
issuedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
signature: 'valid-signature',
syncedAt: new Date().toISOString(),
})}`,
});
}
if (where.key === 'premium_module_entitlements') {
return Promise.resolve({
value: `encrypted:${JSON.stringify({
entitlements: {
'license-limits-core': {
entitlementBase64: 'module-entitlement',
sha256: 'hash',
sizeBytes: 18,
expiresAt: new Date(Date.now() + 60_000).toISOString(),
artifact: limitsModuleArtifact,
syncedAt: new Date().toISOString(),
},
},
})}`,
});
}
if (where.key === 'premium_module_runtime_status') {
return Promise.resolve({
value: `encrypted:${JSON.stringify({
'license-limits-core': {
moduleId: 'license-limits-core',
image: limitsModuleArtifact.image,
digest: null,
status: 'missing',
lastSyncAt: new Date().toISOString(),
},
})}`,
});
}
return Promise.resolve(null);
});
const { getAuthorizedUsageLimits } = await import('../license/premium-module-service');
await expect(getAuthorizedUsageLimits()).resolves.toBeNull();
expect(mockFetch).not.toHaveBeenCalled();
});
it('缓存中的短期拉包凭证有效时才返回给模块运行器', async () => { it('缓存中的短期拉包凭证有效时才返回给模块运行器', async () => {
mockPaidLicense('PRO'); mockPaidLicense('PRO');
(prisma.systemConfig.findUnique as jest.Mock).mockImplementation(({ where }) => { (prisma.systemConfig.findUnique as jest.Mock).mockImplementation(({ where }) => {

View File

@ -5,7 +5,11 @@
import { isFeatureEnabled, getLicenseType } from './license-service'; import { isFeatureEnabled, getLicenseType } from './license-service';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import { getAuthorizedUsageLimits, hasPremiumModule, PREMIUM_MODULE_IDS } from './premium-module-service'; import {
authorizePremiumModuleAction,
getAuthorizedUsageLimits,
PREMIUM_MODULE_IDS,
} from './premium-module-service';
export interface LimitCheckResult { export interface LimitCheckResult {
allowed: boolean; allowed: boolean;
@ -101,7 +105,12 @@ export async function canUploadMaterial(): Promise<LimitCheckResult> {
*/ */
export async function canUseCustomTemplates(): Promise<boolean> { export async function canUseCustomTemplates(): Promise<boolean> {
if (!(await isFeatureEnabled('customTemplates'))) return false; if (!(await isFeatureEnabled('customTemplates'))) return false;
return await hasPremiumModule(PREMIUM_MODULE_IDS.CUSTOM_TEMPLATES_CORE); return (
await authorizePremiumModuleAction(
PREMIUM_MODULE_IDS.CUSTOM_TEMPLATES_CORE,
'customTemplates:use'
)
).available;
} }
/** /**
@ -116,7 +125,12 @@ export async function canUseDepartmentIsolation(): Promise<boolean> {
*/ */
export async function canUseIntranetPenetration(): Promise<boolean> { export async function canUseIntranetPenetration(): Promise<boolean> {
if (!(await isFeatureEnabled('intranetPenetration'))) return false; if (!(await isFeatureEnabled('intranetPenetration'))) return false;
return await hasPremiumModule(PREMIUM_MODULE_IDS.INTRANET_PENETRATION_CORE); return (
await authorizePremiumModuleAction(
PREMIUM_MODULE_IDS.INTRANET_PENETRATION_CORE,
'intranetPenetration:use'
)
).available;
} }
/** /**

View File

@ -37,6 +37,10 @@ export {
getAuthorizedUsageLimits, getAuthorizedUsageLimits,
syncPremiumModules, syncPremiumModules,
hasPremiumModule, hasPremiumModule,
hasUsablePremiumModule,
isPremiumModuleRunning,
authorizePremiumModuleAction,
updatePremiumModuleRuntimeStatus,
} from './premium-module-service'; } from './premium-module-service';
// 导出指纹服务 // 导出指纹服务

View File

@ -109,6 +109,7 @@ const PREMIUM_MODULE_CONFIG_KEY = 'premium_module_manifest';
const PREMIUM_MODULE_ENTITLEMENTS_CONFIG_KEY = 'premium_module_entitlements'; const PREMIUM_MODULE_ENTITLEMENTS_CONFIG_KEY = 'premium_module_entitlements';
const PREMIUM_MODULE_RUNTIME_STATUS_CONFIG_KEY = 'premium_module_runtime_status'; const PREMIUM_MODULE_RUNTIME_STATUS_CONFIG_KEY = 'premium_module_runtime_status';
const PREMIUM_MODULE_PULL_USERNAME = 'flnxi-module'; const PREMIUM_MODULE_PULL_USERNAME = 'flnxi-module';
const PREMIUM_MODULE_HEALTH_TIMEOUT_MS = 3000;
export const PREMIUM_MODULE_IDS = { export const PREMIUM_MODULE_IDS = {
LICENSE_LIMITS_CORE: 'license-limits-core', LICENSE_LIMITS_CORE: 'license-limits-core',
@ -121,6 +122,19 @@ export interface AuthorizedUsageLimits {
maxMaterials: number; maxMaterials: number;
} }
export interface PremiumModuleRuntimeGateResult {
available: boolean;
moduleId: string;
status?: PremiumModuleRuntimeStatusValue;
message?: string;
}
export interface PremiumModuleAuthorizeResult extends PremiumModuleRuntimeGateResult {
action: string;
}
type PremiumModuleActionContext = Record<string, unknown>;
function isManifestExpired(manifest: PremiumModuleManifest): boolean { function isManifestExpired(manifest: PremiumModuleManifest): boolean {
const expiresAt = new Date(manifest.expiresAt).getTime(); const expiresAt = new Date(manifest.expiresAt).getTime();
return Number.isNaN(expiresAt) || expiresAt <= Date.now(); return Number.isNaN(expiresAt) || expiresAt <= Date.now();
@ -283,6 +297,73 @@ function isPullCredentialValid(
return !Number.isNaN(expiresAt) && expiresAt > Date.now(); return !Number.isNaN(expiresAt) && expiresAt > Date.now();
} }
function getPremiumModuleServiceBaseUrl(moduleId: string): string {
const envByModuleId: Record<string, string | undefined> = {
[PREMIUM_MODULE_IDS.LICENSE_LIMITS_CORE]: process.env.LICENSE_LIMITS_MODULE_URL,
[PREMIUM_MODULE_IDS.CUSTOM_TEMPLATES_CORE]: process.env.CUSTOM_TEMPLATES_MODULE_URL,
[PREMIUM_MODULE_IDS.INTRANET_PENETRATION_CORE]: process.env.INTRANET_PENETRATION_MODULE_URL,
};
return (envByModuleId[moduleId] || '').trim().replace(/\/+$/, '');
}
function isRuntimeStatusForModule(
status: PremiumModuleRuntimeStatus | null | undefined,
module: PremiumModuleManifest['modules'][number]
): boolean {
if (!status) return false;
if (status.moduleId !== module.id) return false;
if (status.status !== 'running') return false;
if (status.image !== module.artifact.image) return false;
if ((status.digest || null) !== (module.artifact.digest || null)) return false;
return true;
}
async function fetchModuleJson(
baseUrl: string,
path: string,
init: RequestInit = {}
): Promise<{ ok: boolean; status: number; data: any }> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), PREMIUM_MODULE_HEALTH_TIMEOUT_MS);
try {
const response = await fetch(`${baseUrl}${path}`, {
...init,
signal: controller.signal,
});
const data = await response.json().catch(() => null);
return { ok: response.ok, status: response.status, data };
} finally {
clearTimeout(timer);
}
}
async function checkPremiumModuleHealth(moduleId: string, baseUrl: string): Promise<boolean> {
try {
const result = await fetchModuleJson(baseUrl, '/health', { method: 'GET' });
return result.ok && result.data?.moduleId === moduleId && result.data?.status === 'healthy';
} catch (error) {
console.warn(`[License] 授权模块 ${moduleId} 健康检查失败:`, error);
return false;
}
}
async function getPremiumModuleInfo(
moduleId: string
): Promise<PremiumModuleManifest['modules'][number] | null> {
const cached = await getCachedPremiumModuleManifest();
if (!cached) return null;
return cached.manifest.modules.find((item) => item.id === moduleId) || null;
}
async function hasValidPremiumModuleEntitlement(
module: PremiumModuleManifest['modules'][number]
): Promise<boolean> {
const cachedEntitlements = await getCachedPremiumModuleEntitlements();
return isCachedEntitlementValid(cachedEntitlements, module);
}
async function downloadPremiumModuleEntitlement( async function downloadPremiumModuleEntitlement(
module: PremiumModuleManifest['modules'][number], module: PremiumModuleManifest['modules'][number],
fingerprint: Awaited<ReturnType<typeof generateFingerprint>>, fingerprint: Awaited<ReturnType<typeof generateFingerprint>>,
@ -456,15 +537,20 @@ export async function syncPremiumModules(): Promise<{ synced: boolean; skipped?:
cachedEntitlements.entitlements[moduleInfo.id] = nextEntitlement; cachedEntitlements.entitlements[moduleInfo.id] = nextEntitlement;
const existingStatus = runtimeStatuses[moduleInfo.id]; const existingStatus = runtimeStatuses[moduleInfo.id];
const canCarryRuntimeStatus = Boolean(
existingStatus &&
existingStatus.image === moduleInfo.artifact.image &&
(existingStatus.digest || null) === (moduleInfo.artifact.digest || null) &&
existingStatus.status !== 'expired' &&
existingStatus.status !== 'revoked'
);
runtimeStatuses[moduleInfo.id] = { runtimeStatuses[moduleInfo.id] = {
moduleId: moduleInfo.id, moduleId: moduleInfo.id,
image: moduleInfo.artifact.image, image: moduleInfo.artifact.image,
digest: moduleInfo.artifact.digest || null, digest: moduleInfo.artifact.digest || null,
status: status: canCarryRuntimeStatus && existingStatus ? existingStatus.status : 'missing',
existingStatus?.status && existingStatus.status !== 'expired' && existingStatus.status !== 'revoked'
? existingStatus.status
: 'missing',
lastSyncAt: new Date().toISOString(), lastSyncAt: new Date().toISOString(),
lastError: canCarryRuntimeStatus && existingStatus ? existingStatus.lastError : undefined,
}; };
} }
@ -490,16 +576,15 @@ export async function syncPremiumModules(): Promise<{ synced: boolean; skipped?:
} }
export async function getAuthorizedUsageLimits(): Promise<AuthorizedUsageLimits | null> { export async function getAuthorizedUsageLimits(): Promise<AuthorizedUsageLimits | null> {
const cached = await getCachedPremiumModuleManifest(); const moduleInfo = await getPremiumModuleInfo(PREMIUM_MODULE_IDS.LICENSE_LIMITS_CORE);
if (!cached) return null;
const moduleInfo = cached.manifest.modules.find(
(item) => item.id === PREMIUM_MODULE_IDS.LICENSE_LIMITS_CORE
);
if (!moduleInfo || !isValidUsageLimits(moduleInfo.limits)) return null; if (!moduleInfo || !isValidUsageLimits(moduleInfo.limits)) return null;
const cachedEntitlements = await getCachedPremiumModuleEntitlements(); if (!(await hasValidPremiumModuleEntitlement(moduleInfo))) return null;
if (!isCachedEntitlementValid(cachedEntitlements, moduleInfo)) return null; const authorization = await authorizePremiumModuleAction(
PREMIUM_MODULE_IDS.LICENSE_LIMITS_CORE,
'licenseLimits:enforce'
);
if (!authorization.available) return null;
return { return {
maxUsers: moduleInfo.limits.maxUsers, maxUsers: moduleInfo.limits.maxUsers,
@ -522,12 +607,138 @@ export async function getCachedModulePullCredential(moduleId: string): Promise<P
} }
export async function hasPremiumModule(moduleId: string): Promise<boolean> { export async function hasPremiumModule(moduleId: string): Promise<boolean> {
const cached = await getCachedPremiumModuleManifest(); const moduleInfo = await getPremiumModuleInfo(moduleId);
if (!cached) return false;
const moduleInfo = cached.manifest.modules.find((item) => item.id === moduleId);
if (!moduleInfo) return false; if (!moduleInfo) return false;
const cachedEntitlements = await getCachedPremiumModuleEntitlements(); return hasValidPremiumModuleEntitlement(moduleInfo);
return isCachedEntitlementValid(cachedEntitlements, moduleInfo); }
export async function isPremiumModuleRunning(moduleId: string): Promise<PremiumModuleRuntimeGateResult> {
const moduleInfo = await getPremiumModuleInfo(moduleId);
if (!moduleInfo) {
return {
available: false,
moduleId,
message: '当前许可证未包含该授权模块',
};
}
if (!(await hasValidPremiumModuleEntitlement(moduleInfo))) {
return {
available: false,
moduleId,
message: '授权模块凭证不可用,请重新验证许可证',
};
}
const runtimeStatuses = await getCachedPremiumModuleRuntimeStatus();
const runtimeStatus = runtimeStatuses[moduleId];
if (!isRuntimeStatusForModule(runtimeStatus, moduleInfo)) {
return {
available: false,
moduleId,
status: runtimeStatus?.status || 'missing',
message:
runtimeStatus?.lastError ||
'授权模块尚未运行,请先通过 Agent 拉取并启动对应模块',
};
}
const baseUrl = getPremiumModuleServiceBaseUrl(moduleId);
if (!baseUrl) {
return {
available: false,
moduleId,
status: runtimeStatus.status,
message: '授权模块服务地址未配置,无法确认模块健康状态',
};
}
if (!(await checkPremiumModuleHealth(moduleId, baseUrl))) {
return {
available: false,
moduleId,
status: 'unhealthy',
message: '授权模块健康检查未通过',
};
}
return {
available: true,
moduleId,
status: runtimeStatus.status,
};
}
export async function hasUsablePremiumModule(moduleId: string): Promise<boolean> {
return (await isPremiumModuleRunning(moduleId)).available;
}
export async function authorizePremiumModuleAction(
moduleId: string,
action: string,
context: PremiumModuleActionContext = {}
): Promise<PremiumModuleAuthorizeResult> {
const gate = await isPremiumModuleRunning(moduleId);
if (!gate.available) {
return {
...gate,
action,
};
}
const baseUrl = getPremiumModuleServiceBaseUrl(moduleId);
try {
const result = await fetchModuleJson(baseUrl, '/authorize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, context }),
});
const allowed = result.ok && result.data?.allowed === true && result.data?.moduleId === moduleId;
return {
available: allowed,
moduleId,
action,
status: allowed ? 'running' : 'unhealthy',
message: allowed ? undefined : result.data?.error || '授权模块拒绝该操作',
};
} catch (error) {
const message = error instanceof Error ? error.message : '授权模块操作授权失败';
return {
available: false,
moduleId,
action,
status: 'unhealthy',
message,
};
}
}
export async function updatePremiumModuleRuntimeStatus(status: PremiumModuleRuntimeStatus): Promise<void> {
if (!status.moduleId || !status.image || !status.lastSyncAt) {
throw new Error('授权模块运行状态缺少必要字段');
}
const validStatuses: PremiumModuleRuntimeStatusValue[] = [
'missing',
'pulling',
'running',
'unhealthy',
'expired',
'revoked',
];
if (!validStatuses.includes(status.status)) {
throw new Error('授权模块运行状态无效');
}
const runtimeStatuses = await getCachedPremiumModuleRuntimeStatus();
runtimeStatuses[status.moduleId] = {
moduleId: status.moduleId,
image: status.image,
digest: status.digest || null,
status: status.status,
lastSyncAt: status.lastSyncAt,
lastError: status.lastError,
};
await savePremiumModuleRuntimeStatus(runtimeStatuses);
} }

View File

@ -1,12 +1,14 @@
# custom-templates-core # custom-templates-core
这是 FLNXI 标准版及以上的自定义模板授权模块骨架。 这是 FLNXI 标准版及以上的自定义模板授权执行模块。
当前状态: 当前状态:
- 提供 `/health` 健康检查。 - 提供 `/health` 健康检查。
- 提供 `/status` 能力状态。 - 提供 `/status` 能力状态。
- 第一版先承接“验证系统授权、短期拉包、Agent 运行状态”这条商业化链路;模板业务处理仍在主应用中执行,避免一次性拆散模板、上传、筛选和迁移链路。 - 提供 `/capabilities` 能力清单。
- 提供 `/authorize` 授权决策入口,接收 `{ action, context }`,当前只允许 `customTemplates:use/create/update/delete/migrate/read/list/validate/filter`。
- 第一版先承接自定义模板授权执行层;后续再迁移 CRUD、筛选和数据处理链路,避免一次性拆散模板、上传、筛选和迁移链路。
发布后需要把固定镜像和 digest 写入 `license-server`: 发布后需要把固定镜像和 digest 写入 `license-server`:

View File

@ -2,6 +2,18 @@ import http from 'node:http';
const moduleId = 'custom-templates-core'; const moduleId = 'custom-templates-core';
const port = Number.parseInt(process.env.PORT || '8787', 10); const port = Number.parseInt(process.env.PORT || '8787', 10);
const capabilities = ['customTemplates'];
const allowedActions = new Set([
'customTemplates:use',
'customTemplates:create',
'customTemplates:update',
'customTemplates:delete',
'customTemplates:migrate',
'customTemplates:read',
'customTemplates:list',
'customTemplates:validate',
'customTemplates:filter',
]);
function sendJson(res, statusCode, payload) { function sendJson(res, statusCode, payload) {
const body = JSON.stringify(payload); const body = JSON.stringify(payload);
@ -12,6 +24,45 @@ function sendJson(res, statusCode, payload) {
res.end(body); res.end(body);
} }
function readJsonBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', (chunk) => {
body += chunk;
if (body.length > 1024 * 1024) {
req.destroy();
reject(new Error('request_body_too_large'));
}
});
req.on('end', () => {
if (!body.trim()) {
resolve({});
return;
}
try {
resolve(JSON.parse(body));
} catch {
reject(new Error('invalid_json'));
}
});
req.on('error', reject);
});
}
function sendAuthorization(res, action) {
const allowed = typeof action === 'string' && allowedActions.has(action);
sendJson(res, allowed ? 200 : 403, {
moduleId,
allowed,
action,
capabilities,
});
}
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`); const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
@ -24,12 +75,32 @@ const server = http.createServer((req, res) => {
sendJson(res, 200, { sendJson(res, 200, {
moduleId, moduleId,
status: 'authorization_contract_ready', status: 'authorization_contract_ready',
capabilities: ['customTemplates'], capabilities,
note: '模板业务仍由主应用执行;此模块先承接授权和拉包合同', note: '此模块先承接自定义模板授权执行层;后续再迁移 CRUD、筛选和数据处理链路',
}); });
return; return;
} }
if (req.method === 'GET' && url.pathname === '/capabilities') {
sendJson(res, 200, {
moduleId,
capabilities,
actions: Array.from(allowedActions),
});
return;
}
if (req.method === 'POST' && url.pathname === '/authorize') {
readJsonBody(req)
.then((body) => {
sendAuthorization(res, body.action);
})
.catch((error) => {
sendJson(res, 400, { moduleId, allowed: false, error: error.message });
});
return;
}
sendJson(res, 404, { moduleId, error: '接口不存在' }); sendJson(res, 404, { moduleId, error: '接口不存在' });
}); });

View File

@ -1,11 +1,13 @@
# intranet-penetration-core # intranet-penetration-core
这是 FLNXI 第一批拆分的付费模块镜像骨架,用来承接专业版内网穿透模块的运行合同。 这是 FLNXI 第一批拆分的付费模块镜像,用来承接专业版内网穿透模块的运行合同。
当前状态: 当前状态:
- 提供 `/health` 健康检查。 - 提供 `/health` 健康检查。
- 提供 `/status`、`/start`、`/stop` 三个接口合同。 - 提供 `/status`、`/start`、`/stop` 三个接口合同。
- 提供 `/capabilities` 能力清单。
- 提供 `/authorize` 授权决策入口,接收 `{ action, context }`,当前只允许 `intranetPenetration:status/start/stop/read`。
- 未配置真实穿透提供商时明确返回 `provider_not_configured`,不返回占位成功。 - 未配置真实穿透提供商时明确返回 `provider_not_configured`,不返回占位成功。
发布后需要把固定镜像和 digest 写入 `license-server`: 发布后需要把固定镜像和 digest 写入 `license-server`:

View File

@ -3,6 +3,13 @@ import http from 'node:http';
const moduleId = 'intranet-penetration-core'; const moduleId = 'intranet-penetration-core';
const port = Number.parseInt(process.env.PORT || '8787', 10); const port = Number.parseInt(process.env.PORT || '8787', 10);
const provider = (process.env.TUNNEL_PROVIDER || '').trim(); const provider = (process.env.TUNNEL_PROVIDER || '').trim();
const capabilities = ['intranetPenetration'];
const allowedActions = new Set([
'intranetPenetration:status',
'intranetPenetration:start',
'intranetPenetration:stop',
'intranetPenetration:read',
]);
function sendJson(res, statusCode, payload) { function sendJson(res, statusCode, payload) {
const body = JSON.stringify(payload); const body = JSON.stringify(payload);
@ -13,6 +20,35 @@ function sendJson(res, statusCode, payload) {
res.end(body); res.end(body);
} }
function readJsonBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', (chunk) => {
body += chunk;
if (body.length > 1024 * 1024) {
req.destroy();
reject(new Error('request_body_too_large'));
}
});
req.on('end', () => {
if (!body.trim()) {
resolve({});
return;
}
try {
resolve(JSON.parse(body));
} catch {
reject(new Error('invalid_json'));
}
});
req.on('error', reject);
});
}
function providerNotConfigured(res, action) { function providerNotConfigured(res, action) {
sendJson(res, 501, { sendJson(res, 501, {
moduleId, moduleId,
@ -22,6 +58,16 @@ function providerNotConfigured(res, action) {
}); });
} }
function sendAuthorization(res, action) {
const allowed = typeof action === 'string' && allowedActions.has(action);
sendJson(res, allowed ? 200 : 403, {
moduleId,
allowed,
action,
capabilities,
});
}
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`); const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
@ -39,6 +85,15 @@ const server = http.createServer((req, res) => {
return; return;
} }
if (req.method === 'GET' && url.pathname === '/capabilities') {
sendJson(res, 200, {
moduleId,
capabilities,
actions: Array.from(allowedActions),
});
return;
}
if (req.method === 'POST' && url.pathname === '/start') { if (req.method === 'POST' && url.pathname === '/start') {
if (!provider) { if (!provider) {
providerNotConfigured(res, 'start'); providerNotConfigured(res, 'start');
@ -57,6 +112,17 @@ const server = http.createServer((req, res) => {
return; return;
} }
if (req.method === 'POST' && url.pathname === '/authorize') {
readJsonBody(req)
.then((body) => {
sendAuthorization(res, body.action);
})
.catch((error) => {
sendJson(res, 400, { moduleId, allowed: false, error: error.message });
});
return;
}
sendJson(res, 404, { moduleId, error: '接口不存在' }); sendJson(res, 404, { moduleId, error: '接口不存在' });
}); });

View File

@ -6,6 +6,8 @@
- 提供 `/health` 健康检查。 - 提供 `/health` 健康检查。
- 提供 `/status` 能力状态。 - 提供 `/status` 能力状态。
- 提供 `/capabilities` 能力清单。
- 提供 `/authorize` 授权决策入口,接收 `{ action, context }`,当前只允许 `licenseLimits:check/read/validate/enforce`。
- 提供 `/limits` 合同入口,但具体限额必须来自验证系统签发的实例授权,不在镜像里写死。 - 提供 `/limits` 合同入口,但具体限额必须来自验证系统签发的实例授权,不在镜像里写死。
发布后需要把固定镜像和 digest 写入 `license-server`: 发布后需要把固定镜像和 digest 写入 `license-server`:

View File

@ -2,6 +2,13 @@ import http from 'node:http';
const moduleId = 'license-limits-core'; const moduleId = 'license-limits-core';
const port = Number.parseInt(process.env.PORT || '8787', 10); const port = Number.parseInt(process.env.PORT || '8787', 10);
const capabilities = ['licenseLimits'];
const allowedActions = new Set([
'licenseLimits:check',
'licenseLimits:read',
'licenseLimits:validate',
'licenseLimits:enforce',
]);
function sendJson(res, statusCode, payload) { function sendJson(res, statusCode, payload) {
const body = JSON.stringify(payload); const body = JSON.stringify(payload);
@ -12,6 +19,45 @@ function sendJson(res, statusCode, payload) {
res.end(body); res.end(body);
} }
function readJsonBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', (chunk) => {
body += chunk;
if (body.length > 1024 * 1024) {
req.destroy();
reject(new Error('request_body_too_large'));
}
});
req.on('end', () => {
if (!body.trim()) {
resolve({});
return;
}
try {
resolve(JSON.parse(body));
} catch {
reject(new Error('invalid_json'));
}
});
req.on('error', reject);
});
}
function sendAuthorization(res, action) {
const allowed = typeof action === 'string' && allowedActions.has(action);
sendJson(res, allowed ? 200 : 403, {
moduleId,
allowed,
action,
capabilities,
});
}
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`); const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
@ -24,12 +70,21 @@ const server = http.createServer((req, res) => {
sendJson(res, 200, { sendJson(res, 200, {
moduleId, moduleId,
status: 'ready', status: 'ready',
capabilities: ['licenseLimits'], capabilities,
note: '具体人数和素材数上限以验证系统下发的实例授权为准', note: '具体人数和素材数上限以验证系统下发的实例授权为准',
}); });
return; return;
} }
if (req.method === 'GET' && url.pathname === '/capabilities') {
sendJson(res, 200, {
moduleId,
capabilities,
actions: Array.from(allowedActions),
});
return;
}
if (req.method === 'GET' && url.pathname === '/limits') { if (req.method === 'GET' && url.pathname === '/limits') {
sendJson(res, 200, { sendJson(res, 200, {
moduleId, moduleId,
@ -39,6 +94,17 @@ const server = http.createServer((req, res) => {
return; return;
} }
if (req.method === 'POST' && url.pathname === '/authorize') {
readJsonBody(req)
.then((body) => {
sendAuthorization(res, body.action);
})
.catch((error) => {
sendJson(res, 400, { moduleId, allowed: false, error: error.message });
});
return;
}
sendJson(res, 404, { moduleId, error: '接口不存在' }); sendJson(res, 404, { moduleId, error: '接口不存在' });
}); });