推进付费模块运行边界拆分
This commit is contained in:
parent
504a1d93e3
commit
9f355ded27
@ -11,8 +11,12 @@ LICENSE_PROJECT_CODE=flnxi-sucai
|
||||
# Shared HMAC secret between material app and license server (minimum 32 characters)
|
||||
LICENSE_REQUEST_SIGNING_SECRET=replace-with-shared-license-request-secret
|
||||
|
||||
# Optional paid module service URL. Keep empty unless the module container is running.
|
||||
INTRANET_PENETRATION_MODULE_URL=
|
||||
# Paid module service URLs. Keep the defaults only when module containers run on the same Docker network.
|
||||
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
|
||||
# Customer delivery must pin the full application image tag. Do not rely on latest.
|
||||
|
||||
@ -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 指向当前可用镜像站。
|
||||
|
||||
当前已提供第一批模块源码骨架:`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
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
100
app/api/license/premium-modules/runtime-status/route.ts
Normal file
100
app/api/license/premium-modules/runtime-status/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@ -135,6 +135,10 @@ services:
|
||||
- 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}
|
||||
- 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 使用)
|
||||
- INIT_ADMIN_EMAIL=${INIT_ADMIN_EMAIL:-admin@local.flnxi}
|
||||
- INIT_ADMIN_PASSWORD=${INIT_ADMIN_PASSWORD:-}
|
||||
|
||||
@ -74,7 +74,10 @@ LICENSE_PROJECT_CODE=flnxi-sucai
|
||||
LICENSE_REQUEST_SIGNING_SECRET=<generate-a-shared-license-request-secret>
|
||||
LICENSE_PUBLIC_KEY=<license-server-rsa-public-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_TOTAL_FILE_SIZE_BYTES=10737418240
|
||||
MAX_UPLOAD_FIELD_SIZE_BYTES=2097152
|
||||
@ -91,8 +94,9 @@ MAX_UPLOAD_FIELD_SIZE_BYTES=2097152
|
||||
- `LICENSE_REQUEST_SIGNING_SECRET` 必须和 `license-server` 的同名变量一致,至少 32 字符,用于主应用到验证服务器的请求签名。
|
||||
- `LICENSE_PUBLIC_KEY` 是许可证服务签发许可证时使用的 RSA 公钥,主应用靠它确认许可证没有被伪造。
|
||||
- `LOCAL_ENCRYPTION_KEY` 用于主应用本地加密保存许可证缓存,至少 32 字符,不能使用示例值。
|
||||
- `INTRANET_PENETRATION_MODULE_URL` 是可选项;只有客户购买并运行 `intranet-penetration-core` 模块后才填写。未填写时,内网穿透接口仍会明确返回“真实提供商未接入”。
|
||||
- 授权模块核心本体走镜像站,不走验证服务器大文件下载;`license-server` 需要配置 `MODULE_PULL_TOKEN_SECRET`、`PREMIUM_MODULE_REGISTRY_BASE` 和 `PREMIUM_MODULE_VERSION`,用于给客户实例下发签名模块清单、授权凭证和短期拉包 token。免费版人数/素材数上限也通过 `license-limits-core` 模块下发。
|
||||
- `LICENSE_LIMITS_MODULE_URL`、`CUSTOM_TEMPLATES_MODULE_URL`、`INTRANET_PENETRATION_MODULE_URL` 指向同一 Docker 网络里的模块容器。模块没运行或健康检查失败时,对应人数/素材限额、自定义模板、内网穿透能力都会被服务端拒绝。
|
||||
- `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 可按客户机器内存和磁盘调小。
|
||||
- 不要把真实密码、许可证地址、对象存储密钥写入 Git。
|
||||
|
||||
|
||||
@ -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 原始后端不要直接对客户开放。
|
||||
|
||||
主应用不会直接挂 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
|
||||
premium-modules/license-limits-core
|
||||
@ -163,7 +163,7 @@ premium-modules/custom-templates-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. 后台管理入口
|
||||
|
||||
|
||||
@ -65,7 +65,9 @@ docker run -d \
|
||||
| `FLNXI_POSTGRES_IMAGE` | 安装时使用的 PostgreSQL 镜像;也兼容读取 `POSTGRES_IMAGE` | `postgres:15-alpine` |
|
||||
| `FLNXI_MINIO_IMAGE` | 安装时使用的 MinIO 镜像;也兼容读取 `MINIO_IMAGE` | `minio/minio:RELEASE.2025-04-22T22-12-26Z` |
|
||||
| `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_PROJECT_CODE` | 主应用许可证项目编码,安装素材系统时会写入主应用容器 | 未设置 |
|
||||
| `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 容器配置:
|
||||
|
||||
|
||||
@ -21,6 +21,12 @@ describe('flnxi-agent-go/install.sh', () => {
|
||||
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 安装脚本,不冒充客户最终首装入口', () => {
|
||||
expect(script).toContain('FLNXI Agent 安装脚本')
|
||||
expect(script).toContain('Agent 安装脚本')
|
||||
|
||||
@ -233,6 +233,9 @@ run_agent() {
|
||||
-e "LICENSE_REQUEST_SIGNING_SECRET=${LICENSE_REQUEST_SIGNING_SECRET:-}"
|
||||
-e "LICENSE_PUBLIC_KEY=${LICENSE_PUBLIC_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
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@ -161,7 +162,12 @@ func resolveInstallLicenseConfig(req InstallRequest, secrets InstallSecrets) (In
|
||||
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 {
|
||||
moduleStatusToken := resolveModuleStatusToken(secrets)
|
||||
return []string{
|
||||
"LICENSE_KEY=" + req.LicenseKey,
|
||||
"LICENSE_SERVER_URL=" + licenseConfig.ServerURL,
|
||||
@ -185,6 +191,10 @@ func materialAppEnv(req InstallRequest, secrets InstallSecrets, licenseConfig In
|
||||
"NEXTAUTH_SECRET=" + secrets.NextAuthSecret,
|
||||
"NEXTAUTH_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"
|
||||
envLicensePublicKey = "LICENSE_PUBLIC_KEY"
|
||||
envLocalEncryptionKey = "LOCAL_ENCRYPTION_KEY"
|
||||
envModuleStatusCallbackURL = "FLNXI_MODULE_STATUS_CALLBACK_URL"
|
||||
envModuleStatusCallbackToken = "FLNXI_MODULE_STATUS_CALLBACK_TOKEN"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -267,6 +279,9 @@ var (
|
||||
moduleStatusesMutex sync.RWMutex
|
||||
moduleSyncLocks = map[string]bool{}
|
||||
moduleSyncLocksMutex sync.Mutex
|
||||
moduleCallbackURL string
|
||||
moduleCallbackToken string
|
||||
moduleCallbackMutex sync.RWMutex
|
||||
)
|
||||
|
||||
type InstallStatus struct {
|
||||
@ -545,6 +560,11 @@ func runInstall(dc installDockerClient, req InstallRequest) {
|
||||
|
||||
// 获取宿主机 IP 用于配置公开访问地址(从用户请求中获取,确保是宿主机 IP 而非容器 IP)
|
||||
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, "启动素材系统...")
|
||||
@ -651,6 +671,56 @@ func setModuleStatus(status ModuleRuntimeStatus) {
|
||||
moduleStatusesMutex.Lock()
|
||||
moduleStatuses[status.ModuleID] = status
|
||||
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 {
|
||||
@ -826,7 +896,7 @@ func repoDigestsContain(repoDigests []string, digest string) bool {
|
||||
}
|
||||
|
||||
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{
|
||||
Image: req.Image,
|
||||
Env: moduleEnv(req),
|
||||
|
||||
@ -2,7 +2,10 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@ -115,6 +118,7 @@ func TestMaterialAppEnvIncludesLicenseRuntimeConfig(t *testing.T) {
|
||||
S3Bucket: "materials",
|
||||
NextAuthSecret: "next-secret",
|
||||
LocalEncryptionKey: strings.Repeat("b", 32),
|
||||
ModuleStatusToken: strings.Repeat("c", 32),
|
||||
}
|
||||
licenseConfig := InstallLicenseConfig{
|
||||
ServerURL: "https://license.example.test",
|
||||
@ -135,6 +139,10 @@ func TestMaterialAppEnvIncludesLicenseRuntimeConfig(t *testing.T) {
|
||||
"S3_PUBLIC_DOMAIN",
|
||||
"NEXTAUTH_URL",
|
||||
"APP_PUBLIC_URL",
|
||||
"LICENSE_LIMITS_MODULE_URL",
|
||||
"CUSTOM_TEMPLATES_MODULE_URL",
|
||||
"INTRANET_PENETRATION_MODULE_URL",
|
||||
"MODULE_RUNTIME_STATUS_TOKEN",
|
||||
} {
|
||||
if env[want] == "" {
|
||||
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" {
|
||||
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) {
|
||||
@ -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"
|
||||
|
||||
func validModuleSyncRequest() ModuleSyncRequest {
|
||||
@ -402,6 +523,8 @@ func resetModuleStatusesForTest() {
|
||||
moduleSyncLocksMutex.Lock()
|
||||
moduleSyncLocks = map[string]bool{}
|
||||
moduleSyncLocksMutex.Unlock()
|
||||
|
||||
setModuleStatusCallbackConfig("", "")
|
||||
}
|
||||
|
||||
func envSliceToMap(values []string) map[string]string {
|
||||
|
||||
@ -15,6 +15,7 @@ type InstallSecrets struct {
|
||||
S3Bucket string
|
||||
NextAuthSecret string
|
||||
LocalEncryptionKey string
|
||||
ModuleStatusToken string
|
||||
}
|
||||
|
||||
func generateInstallSecrets() (InstallSecrets, error) {
|
||||
@ -38,6 +39,11 @@ func generateInstallSecrets() (InstallSecrets, error) {
|
||||
return InstallSecrets{}, err
|
||||
}
|
||||
|
||||
moduleStatusToken, err := randomHex(32)
|
||||
if err != nil {
|
||||
return InstallSecrets{}, err
|
||||
}
|
||||
|
||||
return InstallSecrets{
|
||||
DbUser: "flnxi",
|
||||
DbPassword: dbPassword,
|
||||
@ -47,6 +53,7 @@ func generateInstallSecrets() (InstallSecrets, error) {
|
||||
S3Bucket: "flnxi",
|
||||
NextAuthSecret: nextAuthSecret,
|
||||
LocalEncryptionKey: localEncryptionKey,
|
||||
ModuleStatusToken: moduleStatusToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@ -20,8 +20,8 @@ jest.mock('../license/license-service', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('../license/premium-module-service', () => ({
|
||||
authorizePremiumModuleAction: jest.fn(),
|
||||
getAuthorizedUsageLimits: jest.fn(),
|
||||
hasPremiumModule: jest.fn(),
|
||||
PREMIUM_MODULE_IDS: {
|
||||
LICENSE_LIMITS_CORE: 'license-limits-core',
|
||||
CUSTOM_TEMPLATES_CORE: 'custom-templates-core',
|
||||
@ -38,18 +38,28 @@ jest.mock('@/lib/prisma', () => ({
|
||||
|
||||
import { prisma } from '@/lib/prisma';
|
||||
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 mockGetLicenseType = getLicenseType as jest.MockedFunction<typeof getLicenseType>;
|
||||
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 mockMaterialCount = prisma.material.count as jest.MockedFunction<typeof prisma.material.count>;
|
||||
|
||||
describe('canCreateUser', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAuthorizePremiumModuleAction.mockResolvedValue({
|
||||
available: true,
|
||||
moduleId: 'license-limits-core',
|
||||
action: 'licenseLimits:enforce',
|
||||
status: 'running',
|
||||
});
|
||||
});
|
||||
|
||||
it('当未达到限制时应该返回 allowed: true', async () => {
|
||||
@ -109,6 +119,12 @@ describe('canCreateUser', () => {
|
||||
describe('canUploadMaterial', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAuthorizePremiumModuleAction.mockResolvedValue({
|
||||
available: true,
|
||||
moduleId: 'license-limits-core',
|
||||
action: 'licenseLimits:enforce',
|
||||
status: 'running',
|
||||
});
|
||||
});
|
||||
|
||||
it('当未达到限制时应该返回 allowed: true', async () => {
|
||||
@ -171,21 +187,34 @@ describe('功能开关检查', () => {
|
||||
describe('canUseCustomTemplates', () => {
|
||||
it('当功能启用时应该返回 true', async () => {
|
||||
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(mockIsFeatureEnabled).toHaveBeenCalledWith('customTemplates');
|
||||
expect(mockHasPremiumModule).toHaveBeenCalledWith('custom-templates-core');
|
||||
expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith(
|
||||
'custom-templates-core',
|
||||
'customTemplates:use'
|
||||
);
|
||||
});
|
||||
|
||||
it('当功能禁用时应该返回 false', async () => {
|
||||
mockIsFeatureEnabled.mockResolvedValue(false);
|
||||
expect(await canUseCustomTemplates()).toBe(false);
|
||||
expect(mockHasPremiumModule).not.toHaveBeenCalled();
|
||||
expect(mockAuthorizePremiumModuleAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('当缺少付费能力模块凭证时应该返回 false', async () => {
|
||||
it('当付费能力模块未运行或拒绝授权时应该返回 false', async () => {
|
||||
mockIsFeatureEnabled.mockResolvedValue(true);
|
||||
mockHasPremiumModule.mockResolvedValue(false);
|
||||
mockAuthorizePremiumModuleAction.mockResolvedValue({
|
||||
available: false,
|
||||
moduleId: 'custom-templates-core',
|
||||
action: 'customTemplates:use',
|
||||
status: 'missing',
|
||||
});
|
||||
expect(await canUseCustomTemplates()).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -206,21 +235,34 @@ describe('功能开关检查', () => {
|
||||
describe('canUseIntranetPenetration', () => {
|
||||
it('当功能启用时应该返回 true', async () => {
|
||||
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(mockIsFeatureEnabled).toHaveBeenCalledWith('intranetPenetration');
|
||||
expect(mockHasPremiumModule).toHaveBeenCalledWith('intranet-penetration-core');
|
||||
expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith(
|
||||
'intranet-penetration-core',
|
||||
'intranetPenetration:use'
|
||||
);
|
||||
});
|
||||
|
||||
it('当功能禁用时应该返回 false', async () => {
|
||||
mockIsFeatureEnabled.mockResolvedValue(false);
|
||||
expect(await canUseIntranetPenetration()).toBe(false);
|
||||
expect(mockHasPremiumModule).not.toHaveBeenCalled();
|
||||
expect(mockAuthorizePremiumModuleAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('当缺少付费能力模块凭证时应该返回 false', async () => {
|
||||
it('当付费能力模块未运行或拒绝授权时应该返回 false', async () => {
|
||||
mockIsFeatureEnabled.mockResolvedValue(true);
|
||||
mockHasPremiumModule.mockResolvedValue(false);
|
||||
mockAuthorizePremiumModuleAction.mockResolvedValue({
|
||||
available: false,
|
||||
moduleId: 'intranet-penetration-core',
|
||||
action: 'intranetPenetration:use',
|
||||
status: 'unhealthy',
|
||||
});
|
||||
expect(await canUseIntranetPenetration()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -112,7 +112,7 @@ describe('许可证业务入口覆盖', () => {
|
||||
it('高价值付费能力必须同时检查签名模块凭证', () => {
|
||||
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.INTRANET_PENETRATION_CORE');
|
||||
});
|
||||
|
||||
@ -156,6 +156,9 @@ describe('premium-module-service', () => {
|
||||
jest.clearAllMocks();
|
||||
mockFetch.mockReset();
|
||||
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 () => {
|
||||
@ -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);
|
||||
});
|
||||
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');
|
||||
|
||||
@ -491,6 +518,66 @@ describe('premium-module-service', () => {
|
||||
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 () => {
|
||||
mockPaidLicense('PRO');
|
||||
(prisma.systemConfig.findUnique as jest.Mock).mockImplementation(({ where }) => {
|
||||
|
||||
@ -5,7 +5,11 @@
|
||||
|
||||
import { isFeatureEnabled, getLicenseType } from './license-service';
|
||||
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 {
|
||||
allowed: boolean;
|
||||
@ -101,7 +105,12 @@ export async function canUploadMaterial(): Promise<LimitCheckResult> {
|
||||
*/
|
||||
export async function canUseCustomTemplates(): Promise<boolean> {
|
||||
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> {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -37,6 +37,10 @@ export {
|
||||
getAuthorizedUsageLimits,
|
||||
syncPremiumModules,
|
||||
hasPremiumModule,
|
||||
hasUsablePremiumModule,
|
||||
isPremiumModuleRunning,
|
||||
authorizePremiumModuleAction,
|
||||
updatePremiumModuleRuntimeStatus,
|
||||
} from './premium-module-service';
|
||||
|
||||
// 导出指纹服务
|
||||
|
||||
@ -109,6 +109,7 @@ const PREMIUM_MODULE_CONFIG_KEY = 'premium_module_manifest';
|
||||
const PREMIUM_MODULE_ENTITLEMENTS_CONFIG_KEY = 'premium_module_entitlements';
|
||||
const PREMIUM_MODULE_RUNTIME_STATUS_CONFIG_KEY = 'premium_module_runtime_status';
|
||||
const PREMIUM_MODULE_PULL_USERNAME = 'flnxi-module';
|
||||
const PREMIUM_MODULE_HEALTH_TIMEOUT_MS = 3000;
|
||||
|
||||
export const PREMIUM_MODULE_IDS = {
|
||||
LICENSE_LIMITS_CORE: 'license-limits-core',
|
||||
@ -121,6 +122,19 @@ export interface AuthorizedUsageLimits {
|
||||
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 {
|
||||
const expiresAt = new Date(manifest.expiresAt).getTime();
|
||||
return Number.isNaN(expiresAt) || expiresAt <= Date.now();
|
||||
@ -283,6 +297,73 @@ function isPullCredentialValid(
|
||||
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(
|
||||
module: PremiumModuleManifest['modules'][number],
|
||||
fingerprint: Awaited<ReturnType<typeof generateFingerprint>>,
|
||||
@ -456,15 +537,20 @@ export async function syncPremiumModules(): Promise<{ synced: boolean; skipped?:
|
||||
cachedEntitlements.entitlements[moduleInfo.id] = nextEntitlement;
|
||||
|
||||
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] = {
|
||||
moduleId: moduleInfo.id,
|
||||
image: moduleInfo.artifact.image,
|
||||
digest: moduleInfo.artifact.digest || null,
|
||||
status:
|
||||
existingStatus?.status && existingStatus.status !== 'expired' && existingStatus.status !== 'revoked'
|
||||
? existingStatus.status
|
||||
: 'missing',
|
||||
status: canCarryRuntimeStatus && existingStatus ? existingStatus.status : 'missing',
|
||||
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> {
|
||||
const cached = await getCachedPremiumModuleManifest();
|
||||
if (!cached) return null;
|
||||
|
||||
const moduleInfo = cached.manifest.modules.find(
|
||||
(item) => item.id === PREMIUM_MODULE_IDS.LICENSE_LIMITS_CORE
|
||||
);
|
||||
const moduleInfo = await getPremiumModuleInfo(PREMIUM_MODULE_IDS.LICENSE_LIMITS_CORE);
|
||||
if (!moduleInfo || !isValidUsageLimits(moduleInfo.limits)) return null;
|
||||
|
||||
const cachedEntitlements = await getCachedPremiumModuleEntitlements();
|
||||
if (!isCachedEntitlementValid(cachedEntitlements, moduleInfo)) return null;
|
||||
if (!(await hasValidPremiumModuleEntitlement(moduleInfo))) return null;
|
||||
const authorization = await authorizePremiumModuleAction(
|
||||
PREMIUM_MODULE_IDS.LICENSE_LIMITS_CORE,
|
||||
'licenseLimits:enforce'
|
||||
);
|
||||
if (!authorization.available) return null;
|
||||
|
||||
return {
|
||||
maxUsers: moduleInfo.limits.maxUsers,
|
||||
@ -522,12 +607,138 @@ export async function getCachedModulePullCredential(moduleId: string): Promise<P
|
||||
}
|
||||
|
||||
export async function hasPremiumModule(moduleId: string): Promise<boolean> {
|
||||
const cached = await getCachedPremiumModuleManifest();
|
||||
if (!cached) return false;
|
||||
|
||||
const moduleInfo = cached.manifest.modules.find((item) => item.id === moduleId);
|
||||
const moduleInfo = await getPremiumModuleInfo(moduleId);
|
||||
if (!moduleInfo) return false;
|
||||
|
||||
const cachedEntitlements = await getCachedPremiumModuleEntitlements();
|
||||
return isCachedEntitlementValid(cachedEntitlements, moduleInfo);
|
||||
return hasValidPremiumModuleEntitlement(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);
|
||||
}
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
# custom-templates-core
|
||||
|
||||
这是 FLNXI 标准版及以上的自定义模板授权模块骨架。
|
||||
这是 FLNXI 标准版及以上的自定义模板授权执行模块。
|
||||
|
||||
当前状态:
|
||||
|
||||
- 提供 `/health` 健康检查。
|
||||
- 提供 `/status` 能力状态。
|
||||
- 第一版先承接“验证系统授权、短期拉包、Agent 运行状态”这条商业化链路;模板业务处理仍在主应用中执行,避免一次性拆散模板、上传、筛选和迁移链路。
|
||||
- 提供 `/capabilities` 能力清单。
|
||||
- 提供 `/authorize` 授权决策入口,接收 `{ action, context }`,当前只允许 `customTemplates:use/create/update/delete/migrate/read/list/validate/filter`。
|
||||
- 第一版先承接自定义模板授权执行层;后续再迁移 CRUD、筛选和数据处理链路,避免一次性拆散模板、上传、筛选和迁移链路。
|
||||
|
||||
发布后需要把固定镜像和 digest 写入 `license-server`:
|
||||
|
||||
|
||||
@ -2,6 +2,18 @@ import http from 'node:http';
|
||||
|
||||
const moduleId = 'custom-templates-core';
|
||||
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) {
|
||||
const body = JSON.stringify(payload);
|
||||
@ -12,6 +24,45 @@ function sendJson(res, statusCode, payload) {
|
||||
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 url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
|
||||
|
||||
@ -24,8 +75,28 @@ const server = http.createServer((req, res) => {
|
||||
sendJson(res, 200, {
|
||||
moduleId,
|
||||
status: 'authorization_contract_ready',
|
||||
capabilities: ['customTemplates'],
|
||||
note: '模板业务仍由主应用执行;此模块先承接授权和拉包合同',
|
||||
capabilities,
|
||||
note: '此模块先承接自定义模板授权执行层;后续再迁移 CRUD、筛选和数据处理链路',
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
# intranet-penetration-core
|
||||
|
||||
这是 FLNXI 第一批拆分的付费模块镜像骨架,用来承接专业版内网穿透模块的运行合同。
|
||||
这是 FLNXI 第一批拆分的付费模块镜像,用来承接专业版内网穿透模块的运行合同。
|
||||
|
||||
当前状态:
|
||||
|
||||
- 提供 `/health` 健康检查。
|
||||
- 提供 `/status`、`/start`、`/stop` 三个接口合同。
|
||||
- 提供 `/capabilities` 能力清单。
|
||||
- 提供 `/authorize` 授权决策入口,接收 `{ action, context }`,当前只允许 `intranetPenetration:status/start/stop/read`。
|
||||
- 未配置真实穿透提供商时明确返回 `provider_not_configured`,不返回占位成功。
|
||||
|
||||
发布后需要把固定镜像和 digest 写入 `license-server`:
|
||||
|
||||
@ -3,6 +3,13 @@ import http from 'node:http';
|
||||
const moduleId = 'intranet-penetration-core';
|
||||
const port = Number.parseInt(process.env.PORT || '8787', 10);
|
||||
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) {
|
||||
const body = JSON.stringify(payload);
|
||||
@ -13,6 +20,35 @@ function sendJson(res, statusCode, payload) {
|
||||
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) {
|
||||
sendJson(res, 501, {
|
||||
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 url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
|
||||
|
||||
@ -39,6 +85,15 @@ const server = http.createServer((req, res) => {
|
||||
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 (!provider) {
|
||||
providerNotConfigured(res, 'start');
|
||||
@ -57,6 +112,17 @@ const server = http.createServer((req, res) => {
|
||||
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: '接口不存在' });
|
||||
});
|
||||
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
|
||||
- 提供 `/health` 健康检查。
|
||||
- 提供 `/status` 能力状态。
|
||||
- 提供 `/capabilities` 能力清单。
|
||||
- 提供 `/authorize` 授权决策入口,接收 `{ action, context }`,当前只允许 `licenseLimits:check/read/validate/enforce`。
|
||||
- 提供 `/limits` 合同入口,但具体限额必须来自验证系统签发的实例授权,不在镜像里写死。
|
||||
|
||||
发布后需要把固定镜像和 digest 写入 `license-server`:
|
||||
|
||||
@ -2,6 +2,13 @@ import http from 'node:http';
|
||||
|
||||
const moduleId = 'license-limits-core';
|
||||
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) {
|
||||
const body = JSON.stringify(payload);
|
||||
@ -12,6 +19,45 @@ function sendJson(res, statusCode, payload) {
|
||||
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 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, {
|
||||
moduleId,
|
||||
status: 'ready',
|
||||
capabilities: ['licenseLimits'],
|
||||
capabilities,
|
||||
note: '具体人数和素材数上限以验证系统下发的实例授权为准',
|
||||
});
|
||||
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') {
|
||||
sendJson(res, 200, {
|
||||
moduleId,
|
||||
@ -39,6 +94,17 @@ const server = http.createServer((req, res) => {
|
||||
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: '接口不存在' });
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user