Compare commits

..

No commits in common. "cde9638227600a5af429ba86fa10e0a13953d626" and "24bc1f4232687adafe97e170b59f9c18b2f8bf14" have entirely different histories.

24 changed files with 1 additions and 5004 deletions

26
API.md
View File

@ -50,32 +50,6 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
- 后端格式化时间字段统一使用 `KtDateTime extends Date`Entity 通过 `@KtDateTimeColumn(format)`、`@KtCreateDateColumn(format)`、`@KtUpdateDateColumn(format)` 在 TypeORM hydrate 边界转换DTO/外部数据源通过 `@KtDateTimeField(format)` + `transformKtDateTimeFields()` 转换。默认输出 `YYYY-MM-DD HH:mm:ss`,可在装饰器中传入格式字符串;响应包装不做递归遍历。 - 后端格式化时间字段统一使用 `KtDateTime extends Date`Entity 通过 `@KtDateTimeColumn(format)`、`@KtCreateDateColumn(format)`、`@KtUpdateDateColumn(format)` 在 TypeORM hydrate 边界转换DTO/外部数据源通过 `@KtDateTimeField(format)` + `transformKtDateTimeFields()` 转换。默认输出 `YYYY-MM-DD HH:mm:ss`,可在装饰器中传入格式字符串;响应包装不做递归遍历。
- `POST */save` 默认会删除请求体里的 `id`,防止新增接口误用前端主键。 - `POST */save` 默认会删除请求体里的 `id`,防止新增接口误用前端主键。
## Runtime Health
| 方法 | 路径 | 认证 | 说明 |
| ----- | ----------------- | ---- | ---------------------------------- |
| `GET` | `/health/runtime` | 否 | API 运行时健康和配置检查状态 |
该接口返回 plain JSON不使用 Vben 响应包装,供本地 smoke、Jenkins/K8s 和 ktWorkflow 观测脚本直接读取。接口位于 Swagger 基础能力分组 `/api/basic`
顶层字段:
| 字段 | 说明 |
| ----------- | ------------------------------------------------ |
| `service` | 固定为 `kt-template-online-api` |
| `checkedAt` | ISO 时间字符串 |
| `status` | `live`、`ready`、`degraded` 或 `blocked` |
| `checks` | 进程和配置检查列表 |
公开响应不返回数据库、WordPress、Loki、NapCat SSH 等运行拓扑配置快照;配置检查只暴露 key 级别、是否存在和缺失说明。
状态含义:
- `live`NestJS 进程能响应健康请求。
- `ready`:关键配置存在,当前检查未发现缺失项。
- `degraded`:可选运行时配置缺失,核心 API 可继续工作。
- `blocked`:关键运行时配置缺失,不能声明部署或运行态成功。
## 环境变量分组 ## 环境变量分组
| 分组 | 关键变量 | | 分组 | 关键变量 |

View File

@ -121,17 +121,6 @@ pnpm exec jest --runInBand --runTestsByPath test/path/to/file.spec.ts
} }
``` ```
## 运行时健康检查
API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow 观测入口。该接口返回 plain JSON不使用 Vben 响应包装,便于脚本直接读取。
返回内容包括:
- `status``live`、`ready`、`degraded` 或 `blocked`
- `checks`:进程存活和运行时配置检查状态。
该公开入口不返回数据库、WordPress、Loki、NapCat SSH 等运行拓扑配置快照;配置检查只暴露 key 级别、是否存在和缺失说明。`blocked` 表示关键配置缺失;`degraded` 表示可选运行时配置缺失,核心 API 仍可继续工作。本地未配置 Loki、WordPress、NapCat 等可选依赖时,健康状态可能保持 `degraded`
## 核心规则 ## 核心规则
- 后台主键使用 Snowflake 数字 ID数据库字段为 `BIGINT`,接口按字符串返回。 - 后台主键使用 Snowflake 数字 ID数据库字段为 `BIGINT`,接口按字符串返回。

File diff suppressed because it is too large Load Diff

View File

@ -1,494 +0,0 @@
# API Runtime Foundation Design
## Background
The API project has grown into several runtime-heavy domains: Admin, Blog,
WordPress, QQBot, MinIO, Loki logging, Jenkins/K8s deployment, and NAS-hosted
NapCat containers. The current module layout works, but reliability behavior is
spread across large services and scripts.
The most visible pressure points are:
- `src/qqbot/account/qqbot-napcat-login.service.ts` mixes session state, SSE
events, quick login, password login, captcha, QR code fallback, cleanup, and
NapCat WebUI calls.
- `src/qqbot/napcat/qqbot-napcat-container.service.ts` mixes database container
state, SSH process execution, Docker script generation, runtime status checks,
and NapCat WebUI requests.
- `src/wordpress/wordpress.service.ts` mixes WordPress auth, request transport,
data normalization, markdown conversion, public article reads, and theme
integration.
- Runtime configuration is read directly through `ConfigService.get(...)` in
many services, with required keys, defaults, timeouts, and secret masking
handled locally.
- Jenkins/K8s deployment already builds and rolls out the API, but post-rollout
evidence still depends on manual observation and task-specific smoke commands.
The user selected the API-wide refactor priority as:
1. Runtime reliability.
2. Module boundaries.
3. Contract stability.
4. Testability.
The user also selected a combined path: first implement the API runtime
foundation, then extend that line into ktWorkflow/Jenkins/K8s automated
observation.
## Goal
Build an API Runtime Foundation that centralizes configuration, external
runtime calls, health reporting, cleanup semantics, and verification evidence,
then use NapCat and Jenkins/K8s as the first end-to-end reliability samples.
## Non-Goals
- Do not rewrite every API business module in the first phase.
- Do not migrate all Admin, Blog, WordPress, MinIO, QQBot, and common contracts
at once.
- Do not replace existing Vben response wrappers or public API shapes during the
runtime foundation phase unless a sample integration requires a narrow
compatibility change.
- Do not bypass QQ or Tencent security checks. Captcha and new-device
verification stay user-driven.
- Do not commit real secrets, backend `.env.development`, backend
`.env.production`, database passwords, SSH keys, tokens, or production
Kubernetes Secrets.
## Scope
This spec covers the first API-wide subproject:
- Add a runtime foundation inside the API project.
- Connect that foundation to two sample runtime paths:
- API deployment observation through Jenkins/K8s and ktWorkflow.
- QQBot NapCat runtime and login reliability.
- Create guardrails so future refactors move from runtime reliability into
module boundaries, contract stability, and testability without large,
unrelated rewrites.
Future subprojects should receive their own Superpowers specs:
- API module boundary refactor.
- API contract and DTO stability.
- API testability and large-service decomposition.
## Architecture
### Runtime Foundation Layer
Create a focused runtime layer rather than expanding `common` into a larger
utility bucket. The layer owns operational concerns, while business modules keep
domain state and user-facing behavior.
Proposed package boundary:
```text
src/runtime/
runtime.module.ts
config/
client/
health/
evidence/
cleanup/
errors/
```
The exact file split can change during implementation, but each unit must have
one responsibility and a clear consumer:
- Runtime config is consumed by API modules and adapters.
- Runtime clients are consumed by integration adapters, not controllers.
- Runtime health is consumed by health controllers and deployment smoke.
- Runtime evidence is consumed by scripts, ktWorkflow, and final task records.
- Runtime cleanup is consumed by integration adapters that create temporary
runtime state.
### RuntimeConfigModule
`RuntimeConfigModule` provides typed config profiles for the runtime paths that
matter first:
- app and HTTP server settings.
- database connection metadata needed for health checks.
- Loki logging and query settings.
- WordPress integration settings.
- MinIO integration settings.
- QQBot and NapCat runtime settings.
- Jenkins/K8s smoke observation settings where they are needed by scripts.
Each profile must provide:
- typed values with normalized number and boolean parsing.
- required key validation.
- safe defaults where the current project already has stable defaults.
- secret masking for logs, health output, evidence files, and review output.
- a safe snapshot method that never returns raw passwords, tokens, private keys,
or secret values.
`ConfigService.get(...)` may remain in untouched legacy code during migration,
but new runtime code should consume typed config profiles.
### RuntimeHttpClient and RuntimeProcessClient
External calls should flow through runtime clients instead of ad hoc
`fetch`, `http.request`, `https.request`, or `spawn` logic.
`RuntimeHttpClient` handles:
- request timeout.
- operation name.
- safe target summary.
- duration.
- status code.
- response parsing failures.
- classified errors.
`RuntimeProcessClient` handles:
- bounded process execution.
- command family and safe argument summaries.
- stdin script support for NAS SSH here-string workflows.
- duration and exit code.
- timeout classification.
- stdout/stderr truncation for safe evidence.
These clients do not know domain rules. They return transport-level results that
adapters translate into domain state.
### RuntimeHealthService
Add a machine-readable runtime health endpoint such as:
```text
GET /health/runtime
```
The endpoint should support at least these states:
- `live`: the process is running and can answer HTTP requests.
- `ready`: critical dependencies and required config are available.
- `degraded`: optional dependencies are unavailable, but core API behavior can
continue.
- `blocked`: required config is missing or a critical dependency is unavailable.
Initial probes should be lightweight. They should not perform destructive work
or expensive business operations. The first implementation can keep the existing
K8s TCP readiness probe and let Jenkins/ktWorkflow call `/health/runtime`.
Switching the Kubernetes readiness probe to HTTP should happen only after the
endpoint proves stable online.
### RuntimeEvidenceService
Runtime evidence should be emitted as structured JSON under
`.kt-workspace/test-artifacts/...` and summarized in final reports.
Evidence records should include:
- title and task type.
- target project and environment.
- command or endpoint name.
- safe target summary.
- start time, end time, and duration.
- result status.
- classified error when present.
- validation assertions.
- cleanup result.
Evidence must not include full logs, raw tokens, raw passwords, SSH keys, full
base64 image payloads, or production Secret values.
### RuntimeCleanup Semantics
Cleanup must be explicit and reportable. Runtime operations that create
temporary state must return cleanup results instead of burying cleanup failure in
logs.
For NapCat, cleanup failure includes:
- failing to remove runtime login password environment variables.
- failing to clean temporary SSH scripts or runtime artifacts started by this
task.
- failing to preserve or verify required persistent device state.
Cleanup failure must not be overwritten by ordinary offline or login-failed
messages.
### NapCatRuntimeAdapter
NapCat becomes the first domain adapter for the runtime foundation.
It should own runtime concerns that currently sit inside large QQBot services:
- SSH/Docker script execution through `RuntimeProcessClient`.
- Docker device identity persistence.
- NapCat WebUI calls through `RuntimeHttpClient`.
- timeout and retry classification.
- safe runtime evidence for container rebuilds, captcha, new-device verification,
and cleanup.
The QQBot account/login services should keep domain state:
- account binding.
- login session lifecycle.
- SSE progress.
- expected selfId checks.
- success and failure messages.
The adapter must support the confirmed NapCat upstream new-device flow:
1. `PasswordLogin` or `CaptchaLogin` returns `needNewDevice`, `jumpUrl`, and
`newDevicePullQrCodeSig`.
2. API calls `GetNewDeviceQRCode` with `uin` and `jumpUrl`.
3. API keeps the same scan session pending and exposes a user-facing new-device
QR state through SSE/Admin.
4. API polls `PollNewDeviceQR` using `bytesToken`.
5. When the user confirms the QR, API calls `NewDeviceLogin` with `uin`,
`passwordMd5`, and `newDevicePullQrCodeSig`.
6. API checks login state, cleans runtime password state, binds the account, and
records evidence.
### DeployObservationAdapter
Deployment observation starts from ktWorkflow and can reuse API evidence
formats. It should collect:
- Jenkins job and build number.
- commit hash.
- image tag.
- Kubernetes Deployment generation and observedGeneration.
- updated and ready replica counts.
- running Pod selected by image tag.
- restart count.
- recent Pod logs or events when failing.
- `/health/runtime` response.
- task-specific smoke result.
Rollout success alone is deployment evidence, not functional success.
## Data Flow
The target flow is:
```text
Controller or script
-> business service
-> domain adapter
-> runtime config/client/cleanup/health/evidence
-> external system
```
External results flow back as:
```text
external system result
-> runtime client classification
-> domain adapter translation
-> business service state update
-> controller/SSE/API response
-> runtime evidence summary
```
For NapCat login, that means:
```text
Admin refresh login
-> QqbotNapcatLoginService session state
-> NapCatRuntimeAdapter
-> RuntimeProcessClient for Docker/SSH
-> RuntimeHttpClient for NapCat WebUI
-> classified status
-> SSE Chinese progress and scan/status response
-> runtime evidence
```
For deployment observation, that means:
```text
push/commit
-> Jenkins build and K8s rollout
-> ktWorkflow DeployObservationAdapter
-> Kubernetes and health checks
-> task smoke
-> runtime evidence
-> closeout decision
```
## Error Model
Runtime errors should be classified before business code decides the user-facing
message.
### `config_error`
Required configuration is missing, dangerous, or unreadable.
Examples:
- missing database host.
- missing explicit QQBot account secret.
- missing NapCat SSH target.
- unreadable SSH key path.
Result: blocked. Do not claim deploy or runtime success.
### `dependency_unavailable`
The external dependency is unreachable or not authorized.
Examples:
- WordPress timeout.
- Loki query timeout.
- NapCat WebUI unavailable.
- SSH command timeout.
Result: degraded or blocked, depending on dependency criticality.
### `operation_failed`
The dependency is reachable but the business operation did not complete.
Examples:
- NapCat requires captcha.
- NapCat requires new-device verification.
- QQ account mismatch.
- WordPress returns an upstream validation error.
Result: domain service keeps pending, returns a business error, or falls back
according to its state machine.
### `cleanup_failed`
Primary work may have completed or partially completed, but cleanup failed.
Examples:
- `NAPCAT_QUICK_PASSWORD` could not be removed.
- temporary runtime evidence could not be cleaned.
- a remote temporary file created by this run remains.
Result: block success reporting until the cleanup failure is handled or reported
as a stable blocker.
## Phased Delivery
### Phase 1: Runtime Foundation Skeleton
Create the runtime module, typed config profiles, health types, evidence types,
and lightweight `/health/runtime` endpoint.
Initial validation:
- targeted Jest for config parsing, masking, health state aggregation, and
evidence serialization.
- `pnpm run typecheck`.
- one real local or reused-service request to `/health/runtime` when the
endpoint exists.
### Phase 2: Deployment Observation Foundation
Add ktWorkflow/Jenkins/K8s observation support that can use the new health and
evidence format.
Initial validation:
- ktWorkflow typecheck and self-test.
- dry-run or read-only deploy observation against the current API deployment.
- evidence written under `.kt-workspace/test-artifacts`.
### Phase 3: NapCat Runtime Adapter Sample
Refactor the NapCat runtime boundary around device persistence, WebUI calls,
new-device QR flow, runtime password cleanup, and SSH/Docker evidence.
Required behavior:
- Docker container rebuild preserves device identity files:
- `$DATA_DIR/QQ` mounted to `/app/.config/QQ`.
- `$DATA_DIR/device.env` stores stable MAC and hostname.
- `$DATA_DIR/machine-id` mounts to `/etc/machine-id:ro`.
- Docker run uses `--mac-address` and `--hostname`.
- Reset login state must not delete `device.env` or `machine-id`.
- Captcha and new-device verification keep the same scan session pending.
- New-device verification follows `GetNewDeviceQRCode -> PollNewDeviceQR ->
NewDeviceLogin`.
- SSE/Admin progress remains Chinese and user-facing:
- quick login.
- password login.
- captcha required.
- new-device QR generated.
- scanned.
- confirming.
- success or failure.
- Runtime login password cleanup failure blocks success.
Initial validation:
- targeted Jest around NapCat device persistence and new-device login flow.
- API typecheck.
- local or reused service request for changed login endpoints.
- online account smoke after push and rollout.
### Phase 4: Boundary Ratchet
Add guardrails so future runtime work uses the foundation.
Guardrails:
- new external integration code should not directly scatter
`ConfigService.get(...)` without a typed runtime profile.
- new runtime calls should use runtime clients or justify why not.
- new deployment completion claims should include rollout, health, and smoke
evidence.
- new cleanup-sensitive flows should return cleanup evidence.
Validation:
- ktWorkflow global review rule updates.
- ktWorkflow self-test.
- documentation sync for README/API/docs/TASKS/Obsidian entries as required.
## Verification Strategy
Verification scales with risk:
- docs-only spec commit: self-review, `git diff --check`, and scoped review.
- runtime foundation code: targeted Jest and typecheck.
- interface changes: real local or reused-service request.
- deployment automation: read-only observation or dry run first, then online
observation after push.
- NapCat runtime changes: targeted Jest, real API request, Jenkins/K8s rollout,
and online account smoke.
Completion of the full workstream requires:
- KT documentation sync.
- cleanup-history final `deleted=[]` evidence when artifacts are produced.
- KT global review with no blocking findings.
- Superpowers code review evidence.
- ktWorkflow closeout evidence.
## Worktree Cleanliness
The earlier NapCat half-implementation draft was explicitly abandoned before
this spec was committed. Future implementation work should start from a clean
API worktree, apart from committed design and planning documents. If new local
changes appear before implementation starts, inspect and classify them before
editing.
## Acceptance Criteria
The first implementation plan is ready when it can produce these outcomes in
order:
1. API has a runtime foundation skeleton with typed config, health, evidence,
and classified error primitives.
2. Jenkins/K8s/ktWorkflow can observe a deployment using the shared evidence
shape.
3. NapCat login and container runtime become the first real adapter sample.
4. Online verification proves rollout, health, and at least one real runtime
smoke path.
5. The next API refactor phase can start from module boundaries rather than
re-solving runtime reliability.

View File

@ -1,112 +0,0 @@
# QQBot NapCat 登录与设备持久化设计
## 背景
当前 QQBot 的 NapCat 登录链路存在两个耦合问题:
- Docker 重建容器时只持久化 `/app/.config/QQ`,没有固定 MAC、hostname 和 `/etc/machine-id`QQ 侧容易把同一账号识别成新设备。
- 密码登录或验证码登录返回 `needNewDevice` 后,后端只把 `jumpUrl` 透给 Admin没有按 NapCat 上游流程继续执行新设备二维码获取、轮询和确认登录。
上游依据:
- NapCat Docker 官方 compose 固定 `mac_address`,并挂载 QQ 数据目录到 `/app/.config/QQ`
- NapCat 文档说明 Docker 环境中的 QQ 数据目录位于 `/app/.config/QQ`
- NapCat 源码中 Linux GUID 与 `/etc/machine-id` 和 MAC 相关。
- NapCat WebUI 的新设备流程为 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`
## 目标
本次改动以一次完整闭环为目标:
- 重建 NapCat 容器时保持稳定设备身份。
- 将快速登录、密码登录、验证码、新设备验证收敛为后端登录状态机。
- SSE 向 Admin 展示每一步中文进度和必要操作,不暴露内部 token。
- 线上完成一次当前账号的实际登录链路验证。
## 非目标
- 不绕过 QQ 安全验证,不做验证码自动求解。
- 不删除现有 QQ 登录数据,除非用户明确执行重置登录态。
- 不把账号在线状态和容器在线状态合并为同一个状态字段。
- 不新增独立 NapCat 部署方式。
## 运行时设备持久化
每个托管 NapCat 容器的数据目录维护以下持久文件:
- `$DATA_DIR/QQ`:继续挂载到 `/app/.config/QQ`
- `$DATA_DIR/device.env`:保存 `NAPCAT_MAC_ADDRESS``NAPCAT_HOSTNAME`
- `$DATA_DIR/machine-id`:挂载到 `/etc/machine-id:ro`
容器创建或重建时:
1. 如果同名旧容器仍存在,优先读取旧容器的 MAC、hostname 和 `/etc/machine-id`
2. 如果 `$DATA_DIR/device.env``$DATA_DIR/machine-id` 已存在,优先复用持久文件。
3. 如果没有可复用值,则基于容器名生成稳定的本地管理 MAC、hostname 和 32 位 machine-id。
4. `docker run` 固定 `--mac-address`、`--hostname`,并挂载 machine-id 文件。
重置登录态只清理 `$DATA_DIR/QQ` 下的登录数据,不删除 `device.env``machine-id`
## 登录状态机
后端登录链路按固定顺序推进:
1. 快速登录:有历史会话时优先使用 `ACCOUNT/-q`。失败后进入密码登录,不清设备数据。
2. 密码登录:使用账号密码计算 MD5调用 NapCat `PasswordLogin`
3. 验证码阶段:`needCaptcha` 时 SSE 推送验证码 URLAdmin 提交用户完成验证后的 `ticket/randstr/sid`
4. 新设备阶段:`needNewDevice` 时后端保存 `jumpUrl``newDevicePullQrCodeSig`,随后调用 `GetNewDeviceQRCode`
5. 新设备轮询:后端使用 `PollNewDeviceQR` 轮询扫码状态。
6. 新设备确认:扫码确认后调用 `NewDeviceLogin`,成功后检查登录态、清理临时密码环境、绑定账号。
如果 `NewDeviceLogin` 再次返回 `needNewDevice`,状态机重新进入新设备阶段。
## SSE 与 Admin 表现
SSE 保留最近事件刷新页面可以恢复当前阶段。Admin 只展示用户需要知道的状态:
- 正在尝试快速登录
- 快速登录失败,尝试密码登录
- 密码登录需要 QQ 安全验证
- QQ 需要新设备验证
- 新设备二维码已生成
- 已扫码,等待确认
- 新设备确认中
- 登录成功
- 登录失败及原因
Admin 不直接展示或保存 NapCat 内部轮询 token。二维码继续复用现有扫码区域展示。
## 错误处理
- 密码环境清理失败必须阻断成功,不允许把登录成功和清理失败混在一起。
- 普通 WebSocket close 不清空已有明确下线原因。
- `isOnline:false` 只能表示账号离线,不能表示容器离线。
- 新设备二维码过期时保持 session pending并允许刷新当前阶段二维码。
- 重建容器失败时保留旧 QQ 数据目录和设备持久文件,错误写入账号或容器状态。
## 验证计划
后端单测:
- Docker 创建脚本包含稳定 `device.env`、`machine-id`、`--mac-address`、`--hostname` 和 QQ 数据挂载。
- 登录服务覆盖 `CaptchaLogin -> needNewDevice -> GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`
- 新设备阶段刷新状态不会误判登录成功,也不会提前清理密码环境。
本地验证:
- 运行相关 Jest 单测。
- 运行类型检查。
线上验证:
- 推送后观察 Jenkins、K8s rollout、Pod 日志。
- 当前账号执行更新登录,确认 SSE 进度包含验证码和新设备阶段。
- 重建同一 NapCat 容器后检查 MAC、hostname、machine-id、QQ 数据目录仍复用同一持久值。
- 登录成功后确认容器环境不再残留临时 `NAPCAT_QUICK_PASSWORD`
## 完成标准
- 代码、测试、文档和线上验证证据完整。
- 线上账号完成一次可用登录闭环。
- 不再因普通容器重建导致同一账号反复被识别为新设备。
- KT workflow 记录本次稳定问题点和解决方案。

View File

@ -18,7 +18,6 @@ import { AdminModule } from './admin/admin.module';
import { BlogModule } from './blog/blog.module'; import { BlogModule } from './blog/blog.module';
import { WordpressModule } from './wordpress/wordpress.module'; import { WordpressModule } from './wordpress/wordpress.module';
import { QqbotModule } from './qqbot/qqbot.module'; import { QqbotModule } from './qqbot/qqbot.module';
import { RuntimeModule } from './runtime';
@Module({ @Module({
imports: [ imports: [
@ -65,7 +64,6 @@ import { RuntimeModule } from './runtime';
}), }),
MinioClientModule, MinioClientModule,
CommonModule, CommonModule,
RuntimeModule,
AdminModule, AdminModule,
BlogModule, BlogModule,
WordpressModule, WordpressModule,

View File

@ -258,19 +258,6 @@ function applyOperationResponseExamples(
return; return;
} }
if (isRuntimeHealthPath(path)) {
const plainResponse = buildPlainJsonResponse(
runtimeHealthExample(),
'API 运行时健康检查',
);
operation.responses['200'] = mergeJsonResponse(
operation.responses['200'],
plainResponse,
);
applyErrorResponses(operation);
return;
}
const dataExample = getOperationDataExample(path, method, operation); const dataExample = getOperationDataExample(path, method, operation);
const successSchema = createOperationSuccessSchema( const successSchema = createOperationSuccessSchema(
document, document,
@ -326,24 +313,6 @@ function buildSuccessResponse(dataExample: any, schema: SwaggerSchema) {
}; };
} }
function buildPlainJsonResponse(example: any, description: string) {
return {
description,
content: {
'application/json': {
schema: schemaFromExample(example),
example,
examples: {
success: {
summary: '成功响应',
value: example,
},
},
},
},
};
}
function buildErrorResponse(status: number, summary: string, message: string) { function buildErrorResponse(status: number, summary: string, message: string) {
return { return {
description: message, description: message,
@ -736,10 +705,6 @@ function isBinaryResponsePath(path: string) {
return path.includes('/download') || path.includes('/resource-proxy'); return path.includes('/download') || path.includes('/resource-proxy');
} }
function isRuntimeHealthPath(path: string) {
return path.toLowerCase() === '/health/runtime';
}
function itemExampleByPath(path: string) { function itemExampleByPath(path: string) {
if (path.includes('/qqbot/account')) return qqbotAccountExample(); if (path.includes('/qqbot/account')) return qqbotAccountExample();
if (path.includes('/qqbot/command')) return qqbotCommandExample(); if (path.includes('/qqbot/command')) return qqbotCommandExample();
@ -981,28 +946,6 @@ function pluginHealthExample() {
}; };
} }
function runtimeHealthExample() {
return {
service: 'kt-template-online-api',
checkedAt: '2026-06-13T00:00:00.000Z',
status: 'degraded',
checks: [
{
name: 'process',
status: 'live',
critical: true,
message: 'NestJS process is responding',
},
{
name: 'config:QQBOT_NAPCAT_IMAGE',
status: 'degraded',
critical: false,
message: 'QQBOT_NAPCAT_IMAGE is not configured',
},
],
};
}
function wordpressArticleExample() { function wordpressArticleExample() {
return { return {
id: 1, id: 1,

View File

@ -41,8 +41,7 @@ const swaggerGroups: SwaggerDocumentGroup[] = [
path: 'api/wordpress', path: 'api/wordpress',
}, },
{ {
matcher: (path) => matcher: (path) => path === '/' || path.startsWith('/minio'),
path === '/' || path.startsWith('/minio') || path.startsWith('/health'),
name: '基础能力', name: '基础能力',
path: 'api/basic', path: 'api/basic',
}, },

View File

@ -1,265 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ToolsService } from '../../common';
import {
RuntimeAppConfig,
RuntimeConfigCheck,
RuntimeConfigCheckLevel,
RuntimeDatabaseConfig,
RuntimeLokiConfig,
RuntimeMinioConfig,
RuntimeQqbotConfig,
RuntimeSafeConfigSnapshot,
RuntimeWordpressConfig,
} from './runtime-config.types';
const REQUIRED_CONFIG_KEYS = [
'DB_HOST',
'DB_PORT',
'DB_USERNAME',
'DB_PASSWORD',
'DB_DATABASE',
'ADMIN_TOKEN_SECRET',
] as const;
const OPTIONAL_CONFIG_CHECKS: ReadonlyArray<string | readonly string[]> = [
'MINIO_ENDPOINT',
'MINIO_PORT',
'MINIO_ACCESS_KEY',
'MINIO_SECRET_KEY',
'MINIO_BUCKET',
['LOKI_HOST', 'LOKI_URL'],
['LOKI_QUERY_HOST', 'LOKI_HOST', 'LOKI_URL'],
'LOKI_ENV',
'LOKI_HTTP_REQUEST_PUSH_ENABLED',
'LOKI_TENANT_ID',
'LOKI_USERNAME',
'LOKI_PASSWORD',
'LOKI_PUSH_ENDPOINT',
'LOKI_QUERY_ENDPOINT',
'LOKI_PUSH_TIMEOUT_MS',
'LOKI_QUERY_TIMEOUT_MS',
'LOKI_BATCH_INTERVAL_SECONDS',
'LOKI_BATCH_MAX_BUFFER_SIZE',
'WORDPRESS_BASE_URL',
'WORDPRESS_HOST_HEADER',
'WORDPRESS_ADMIN_USERNAME',
'WORDPRESS_ADMIN_PASSWORD',
'WORDPRESS_TIMEOUT_MS',
'WORDPRESS_LOGIN_TIMEOUT_MS',
'WORDPRESS_AVAILABILITY_TTL_MS',
'QQBOT_REVERSE_WS_PATH',
'QQBOT_REVERSE_WS_TOKEN',
'QQBOT_NAPCAT_ROOT',
'QQBOT_NAPCAT_IMAGE',
'QQBOT_NAPCAT_CONTAINER_MODE',
'QQBOT_NAPCAT_SSH_TARGET',
'QQBOT_NAPCAT_SSH_PORT',
'QQBOT_NAPCAT_SSH_KEY_PATH',
['QQBOT_NAPCAT_REVERSE_WS_URL', 'QQBOT_NAPCAT_REVERSE_WS_BASE'],
['NAPCAT_WEBUI_BASE_URL', 'QQBOT_NAPCAT_WEBUI_URL'],
['NAPCAT_WEBUI_TOKEN', 'QQBOT_NAPCAT_WEBUI_TOKEN'],
];
@Injectable()
export class RuntimeConfigService {
constructor(
private readonly configService: ConfigService,
private readonly toolsService: ToolsService,
) {}
readAppProfile(): RuntimeAppConfig {
return {
nodeEnv: this.getString('NODE_ENV', 'development'),
port: 48085,
};
}
readDatabaseProfile(): RuntimeDatabaseConfig {
return {
host: this.getString('DB_HOST'),
port: this.getPositiveNumber('DB_PORT', 3306),
database: this.getString('DB_DATABASE'),
username: this.getString('DB_USERNAME'),
synchronize: this.getBoolean('DB_SYNC', false),
};
}
readLokiProfile(): RuntimeLokiConfig {
const host = this.getFirstString(['LOKI_HOST', 'LOKI_URL']);
const queryHost = this.getFirstString([
'LOKI_QUERY_HOST',
'LOKI_HOST',
'LOKI_URL',
]);
return {
transportEnabled: !!host,
httpRequestPushEnabled:
!!host && this.getBoolean('LOKI_HTTP_REQUEST_PUSH_ENABLED', true),
queryConfigured: !!queryHost,
host,
queryHost,
environment: this.getString(
'LOKI_ENV',
this.getString('NODE_ENV', 'development'),
),
tenantId: this.getString('LOKI_TENANT_ID'),
username: this.getString('LOKI_USERNAME'),
passwordConfigured: !!this.getString('LOKI_PASSWORD'),
};
}
readMinioProfile(): RuntimeMinioConfig {
return {
endpoint: this.getString('MINIO_ENDPOINT'),
port: this.getPositiveNumber('MINIO_PORT', 9000),
useSSL: false,
accessKey: this.maskSecret(this.configService.get('MINIO_ACCESS_KEY')),
bucket: this.getString('MINIO_BUCKET', 'kt-template-online'),
};
}
readWordpressProfile(): RuntimeWordpressConfig {
const timeoutMs = this.getPositiveNumber('WORDPRESS_TIMEOUT_MS', 15000);
return {
baseUrl: this.getString('WORDPRESS_BASE_URL'),
hostHeader: this.getString('WORDPRESS_HOST_HEADER'),
adminUsername: this.getString('WORDPRESS_ADMIN_USERNAME'),
passwordConfigured: !!this.getString('WORDPRESS_ADMIN_PASSWORD'),
timeoutMs,
loginTimeoutMs: this.getPositiveNumber(
'WORDPRESS_LOGIN_TIMEOUT_MS',
this.getPositiveNumber('WORDPRESS_TIMEOUT_MS', 3000),
),
availabilityTtlMs: this.getPositiveNumber(
'WORDPRESS_AVAILABILITY_TTL_MS',
60_000,
),
};
}
readQqbotProfile(): RuntimeQqbotConfig {
return {
reverseWsPath: this.getString(
'QQBOT_REVERSE_WS_PATH',
'/qqbot/onebot/reverse',
),
reverseWsToken: this.maskSecret(
this.configService.get('QQBOT_REVERSE_WS_TOKEN'),
),
napcatRoot: this.getString(
'QQBOT_NAPCAT_ROOT',
'/vol1/docker/kt-qqbot/napcat-instances',
),
napcatImage: this.getString('QQBOT_NAPCAT_IMAGE'),
napcatContainerMode: this.getString('QQBOT_NAPCAT_CONTAINER_MODE'),
napcatSshTarget: this.getString('QQBOT_NAPCAT_SSH_TARGET', 'nas'),
napcatSshPort: this.getPositiveNumber('QQBOT_NAPCAT_SSH_PORT', 22),
napcatSshKeyPath: this.getString('QQBOT_NAPCAT_SSH_KEY_PATH'),
napcatReverseWsBase: this.getFirstString([
'QQBOT_NAPCAT_REVERSE_WS_URL',
'QQBOT_NAPCAT_REVERSE_WS_BASE',
]),
napcatWebuiBaseUrl: this.getFirstString([
'NAPCAT_WEBUI_BASE_URL',
'QQBOT_NAPCAT_WEBUI_URL',
]),
napcatWebuiToken: this.maskSecret(
this.getFirstString(['NAPCAT_WEBUI_TOKEN', 'QQBOT_NAPCAT_WEBUI_TOKEN']),
),
};
}
getSafeSnapshot(): RuntimeSafeConfigSnapshot {
return {
app: this.readAppProfile(),
database: this.readDatabaseProfile(),
loki: this.readLokiProfile(),
minio: this.readMinioProfile(),
wordpress: this.readWordpressProfile(),
qqbot: this.readQqbotProfile(),
checks: this.getConfigChecks(),
};
}
getConfigChecks(): RuntimeConfigCheck[] {
return [
...REQUIRED_CONFIG_KEYS.map((key) => this.createCheck(key, 'required')),
...OPTIONAL_CONFIG_CHECKS.map((check) =>
typeof check === 'string'
? this.createCheck(check, 'optional')
: this.createAnyCheck([...check], 'optional'),
),
];
}
maskSecret(value: unknown): string {
const text = this.toolsService.toSecretText(value);
if (!text) return '';
if (text.length <= 4) return '****';
return `${text.slice(0, 2)}***${text.slice(-2)}`;
}
private createCheck(
key: string,
level: RuntimeConfigCheckLevel,
): RuntimeConfigCheck {
const value = this.configService.get(key);
const text = this.toolsService.toSecretText(value);
const present = !!text;
return {
key,
level,
present,
maskedValue: present ? this.maskSecret(value) : undefined,
message: present ? undefined : `${key} is not configured`,
};
}
private createAnyCheck(
keys: string[],
level: RuntimeConfigCheckLevel,
): RuntimeConfigCheck {
const key = keys.join('|');
const value = this.getFirstString(keys);
const present = !!value;
return {
key,
level,
present,
maskedValue: present ? this.maskSecret(value) : undefined,
message: present ? undefined : `${key} is not configured`,
};
}
private getString(key: string, fallback = '') {
const value = this.toolsService.toTrimmedString(this.configService.get(key));
return value || fallback;
}
private getFirstString(keys: string[], fallback = '') {
for (const key of keys) {
const value = this.getString(key);
if (value) return value;
}
return fallback;
}
private getPositiveNumber(key: string, fallback: number) {
return this.toolsService.toPositiveNumber(
this.configService.get<string | number>(key),
fallback,
);
}
private getBoolean(key: string, fallback: boolean) {
return this.toolsService.normalizeBoolean(
this.configService.get<string | boolean | number>(key),
fallback,
);
}
}

View File

@ -1,76 +0,0 @@
export type RuntimeConfigCheckLevel = 'required' | 'optional';
export interface RuntimeConfigCheck {
key: string;
level: RuntimeConfigCheckLevel;
present: boolean;
maskedValue?: string;
message?: string;
}
export interface RuntimeAppConfig {
nodeEnv: string;
port: number;
}
export interface RuntimeDatabaseConfig {
host: string;
port: number;
database: string;
username: string;
synchronize: boolean;
}
export interface RuntimeLokiConfig {
transportEnabled: boolean;
httpRequestPushEnabled: boolean;
queryConfigured: boolean;
host: string;
queryHost: string;
environment: string;
tenantId: string;
username: string;
passwordConfigured: boolean;
}
export interface RuntimeMinioConfig {
endpoint: string;
port: number;
useSSL: boolean;
accessKey: string;
bucket: string;
}
export interface RuntimeWordpressConfig {
baseUrl: string;
hostHeader: string;
adminUsername: string;
passwordConfigured: boolean;
timeoutMs: number;
loginTimeoutMs: number;
availabilityTtlMs: number;
}
export interface RuntimeQqbotConfig {
reverseWsPath: string;
reverseWsToken: string;
napcatRoot: string;
napcatImage: string;
napcatContainerMode: string;
napcatSshTarget: string;
napcatSshPort: number;
napcatSshKeyPath: string;
napcatReverseWsBase: string;
napcatWebuiBaseUrl: string;
napcatWebuiToken: string;
}
export interface RuntimeSafeConfigSnapshot {
app: RuntimeAppConfig;
database: RuntimeDatabaseConfig;
loki: RuntimeLokiConfig;
minio: RuntimeMinioConfig;
wordpress: RuntimeWordpressConfig;
qqbot: RuntimeQqbotConfig;
checks: RuntimeConfigCheck[];
}

View File

@ -1,13 +0,0 @@
export type RuntimeErrorCategory =
| 'config_error'
| 'dependency_unavailable'
| 'operation_failed'
| 'cleanup_failed';
export interface RuntimeClassifiedError {
category: RuntimeErrorCategory;
operation: string;
message: string;
cause?: string;
retryable: boolean;
}

View File

@ -1,112 +0,0 @@
import { Injectable } from '@nestjs/common';
import {
RuntimeEvidenceInput,
RuntimeEvidenceRecord,
} from './runtime-evidence.types';
const REDACTED_VALUE = '<redacted>';
const REDACTED_BASE64_VALUE = '<redacted-base64>';
const SENSITIVE_KEY_PATTERN =
/password|secret|token|authorization|cookie|privatekey|sshkey|accesskey|apikey|ticket|randstr|replytext|base64/i;
const SENSITIVE_TEXT_KEY_PATTERN =
'(?:[A-Za-z0-9_-]*(?:password|secret|token|authorization|cookie|private[_-]?key|ssh[_-]?key|access[_-]?key|api[_-]?key|ticket|randstr|replyText|base64)[A-Za-z0-9_-]*|sid)';
const SENSITIVE_TEXT_REPLACEMENTS: Array<[RegExp, string]> = [
[
/data:[a-z0-9.+-]+\/[a-z0-9.+-]+;base64,[a-z0-9+/=\r\n]+/gi,
REDACTED_BASE64_VALUE,
],
[/\b[A-Za-z0-9+/]{120,}={0,2}\b/g, REDACTED_BASE64_VALUE],
[/\b(Authorization)\s*[:=]\s*[^\r\n]+/gi, '$1=<redacted>'],
[/\b(Cookie)\s*[:=]\s*[^\r\n]+/gi, '$1=<redacted>'],
[
/\b((?:private|ssh)[_-]?key)(\s*[:=]\s*)-----BEGIN[\s\S]*?-----END [^-]+-----/gi,
'$1$2<redacted>',
],
[
/\b((?:private|ssh)[_-]?key)(\s*[:=]\s*)-----BEGIN[\s\S]*?(?=\s+[A-Za-z0-9_-]+\s*[:=]|\s*$)/gi,
'$1$2<redacted>',
],
[
new RegExp(
`\\b(${SENSITIVE_TEXT_KEY_PATTERN})(\\s*[:=]\\s*)Bearer\\s+[^\\s,;&]+`,
'gi',
),
'$1$2<redacted>',
],
[
new RegExp(
`(["'])(${SENSITIVE_TEXT_KEY_PATTERN})\\1\\s*:\\s*(?:(["'])[^"']*\\3|[-+]?\\d+(?:\\.\\d+)?|true|false|null)`,
'gi',
),
'$1$2$1:"<redacted>"',
],
[
new RegExp(
`\\b(${SENSITIVE_TEXT_KEY_PATTERN})(\\s*[:=]\\s*)(["'])[^"']*\\3`,
'gi',
),
'$1$2$3<redacted>$3',
],
[
new RegExp(
`\\b(${SENSITIVE_TEXT_KEY_PATTERN})(\\s*[:=]\\s*)[^\\s,;&]+`,
'gi',
),
'$1$2<redacted>',
],
];
@Injectable()
export class RuntimeEvidenceService {
createRecord(input: RuntimeEvidenceInput): RuntimeEvidenceRecord {
const startedAt = input.startedAt ?? new Date();
const endedAt = input.endedAt ?? new Date();
const record: RuntimeEvidenceRecord = {
...input,
startedAt,
endedAt,
durationMs: Math.max(0, endedAt.getTime() - startedAt.getTime()),
schemaVersion: 1,
};
return this.sanitizeValue(record) as RuntimeEvidenceRecord;
}
private sanitizeValue(value: unknown): unknown {
if (value instanceof Date) return value;
if (typeof value === 'string') return this.sanitizeText(value);
if (Array.isArray(value)) {
return value.map((item) => this.sanitizeValue(item));
}
if (this.isPlainRecord(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
key,
this.isSensitiveKey(key) ? REDACTED_VALUE : this.sanitizeValue(entry),
]),
);
}
return value;
}
private sanitizeText(value: string) {
return SENSITIVE_TEXT_REPLACEMENTS.reduce(
(text, [pattern, replacement]) => text.replace(pattern, replacement),
value,
);
}
private isPlainRecord(value: unknown): value is Record<string, unknown> {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
!(value instanceof Date)
);
}
private isSensitiveKey(key: string) {
const normalizedKey = key.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
return normalizedKey === 'sid' || SENSITIVE_KEY_PATTERN.test(normalizedKey);
}
}

View File

@ -1,42 +0,0 @@
import type { RuntimeClassifiedError } from '../errors/runtime-error.types';
export type RuntimeEvidenceStatus =
| 'passed'
| 'failed'
| 'blocked'
| 'skipped';
export interface RuntimeEvidenceCleanupResult {
status: RuntimeEvidenceStatus;
message: string;
details?: Record<string, unknown>;
}
export interface RuntimeEvidenceAssertion {
name: string;
passed: boolean;
message: string;
}
export interface RuntimeEvidenceInput {
title: string;
taskType: string;
project: string;
environment: string;
operation: string;
target?: string;
status: RuntimeEvidenceStatus;
startedAt?: Date;
endedAt?: Date;
details?: Record<string, unknown>;
assertions?: RuntimeEvidenceAssertion[];
cleanup?: RuntimeEvidenceCleanupResult;
error?: RuntimeClassifiedError;
}
export interface RuntimeEvidenceRecord extends RuntimeEvidenceInput {
startedAt: Date;
endedAt: Date;
durationMs: number;
schemaVersion: 1;
}

View File

@ -1,16 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuntimeHealthService } from './runtime-health.service';
import type { RuntimeHealthReport } from './runtime-health.types';
@ApiTags('Runtime Health')
@Controller('health')
export class RuntimeHealthController {
constructor(private readonly runtimeHealthService: RuntimeHealthService) {}
@Get('runtime')
@ApiOperation({ summary: 'Get machine-readable API runtime health' })
getRuntimeHealth(): RuntimeHealthReport {
return this.runtimeHealthService.getRuntimeHealth();
}
}

View File

@ -1,63 +0,0 @@
import { Injectable } from '@nestjs/common';
import { RuntimeConfigService } from '../config/runtime-config.service';
import {
RuntimeHealthCheck,
RuntimeHealthReport,
RuntimeHealthStatus,
} from './runtime-health.types';
@Injectable()
export class RuntimeHealthService {
constructor(private readonly runtimeConfigService: RuntimeConfigService) {}
getRuntimeHealth(): RuntimeHealthReport {
const config = this.runtimeConfigService.getSafeSnapshot();
const checks: RuntimeHealthCheck[] = [
{
name: 'process',
status: 'live',
critical: true,
message: 'NestJS process answered runtime health request',
},
...config.checks.map((check) => ({
name: `config:${check.key}`,
status: this.getConfigCheckStatus(check.present, check.level),
critical: check.level === 'required',
message: check.present
? `${check.key} is configured`
: check.message ?? `${check.key} is not configured`,
})),
];
return {
service: 'kt-template-online-api',
checkedAt: new Date().toISOString(),
status: this.aggregateStatus(checks),
checks,
};
}
private getConfigCheckStatus(
present: boolean,
level: 'required' | 'optional',
): RuntimeHealthStatus {
if (present) return 'ready';
return level === 'required' ? 'blocked' : 'degraded';
}
private aggregateStatus(checks: RuntimeHealthCheck[]): RuntimeHealthStatus {
if (checks.some((check) => check.critical && check.status === 'blocked')) {
return 'blocked';
}
if (checks.some((check) => check.status === 'degraded')) {
return 'degraded';
}
if (checks.every((check) => check.status === 'live')) {
return 'live';
}
return 'ready';
}
}

View File

@ -1,16 +0,0 @@
export type RuntimeHealthStatus = 'live' | 'ready' | 'degraded' | 'blocked';
export interface RuntimeHealthCheck {
name: string;
status: RuntimeHealthStatus;
critical: boolean;
message: string;
detail?: Record<string, unknown>;
}
export interface RuntimeHealthReport {
service: 'kt-template-online-api';
checkedAt: string;
status: RuntimeHealthStatus;
checks: RuntimeHealthCheck[];
}

View File

@ -1,9 +0,0 @@
export * from './config/runtime-config.service';
export * from './config/runtime-config.types';
export * from './errors/runtime-error.types';
export * from './evidence/runtime-evidence.service';
export * from './evidence/runtime-evidence.types';
export * from './health/runtime-health.controller';
export * from './health/runtime-health.service';
export * from './health/runtime-health.types';
export * from './runtime.module';

View File

@ -1,19 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { CommonModule } from '../common';
import { RuntimeConfigService } from './config/runtime-config.service';
import { RuntimeEvidenceService } from './evidence/runtime-evidence.service';
import { RuntimeHealthController } from './health/runtime-health.controller';
import { RuntimeHealthService } from './health/runtime-health.service';
@Module({
imports: [ConfigModule, CommonModule],
controllers: [RuntimeHealthController],
providers: [
RuntimeConfigService,
RuntimeEvidenceService,
RuntimeHealthService,
],
exports: [RuntimeConfigService, RuntimeEvidenceService, RuntimeHealthService],
})
export class RuntimeModule {}

View File

@ -1,48 +0,0 @@
import type { OpenAPIObject } from '@nestjs/swagger';
import { applySwaggerResponseExamples } from '../../src/common/swagger/swagger-response';
describe('applySwaggerResponseExamples', () => {
it('keeps runtime health swagger response as plain JSON instead of Vben success wrapper', () => {
const document = {
openapi: '3.0.0',
info: {
title: 'KT API',
version: 'test',
},
paths: {
'/health/runtime': {
get: {
summary: 'Get machine-readable API runtime health',
responses: {},
},
},
},
} as unknown as OpenAPIObject;
applySwaggerResponseExamples(document);
const operation = (document.paths['/health/runtime'] as any).get;
const jsonContent = operation.responses['200'].content['application/json'];
expect(jsonContent.example).toEqual(
expect.objectContaining({
service: 'kt-template-online-api',
status: 'degraded',
checks: expect.any(Array),
}),
);
expect(jsonContent.example).not.toHaveProperty('code');
expect(jsonContent.example).not.toHaveProperty('msg');
expect(jsonContent.example).not.toHaveProperty('data');
expect(jsonContent.example).not.toHaveProperty('config');
expect(jsonContent.schema).toEqual(
expect.objectContaining({
type: 'object',
properties: expect.objectContaining({
service: expect.any(Object),
checks: expect.any(Object),
}),
}),
);
});
});

View File

@ -1,201 +0,0 @@
import { ConfigService } from '@nestjs/config';
import { ToolsService } from '../../src/common';
import { RuntimeConfigService } from '../../src/runtime/config/runtime-config.service';
function createService(values: Record<string, unknown>) {
const configService = {
get: jest.fn((key: string) => values[key]),
} as unknown as ConfigService;
return new RuntimeConfigService(configService, new ToolsService());
}
describe('RuntimeConfigService', () => {
it('parses app and database profiles with stable defaults', () => {
const service = createService({
DB_HOST: '127.0.0.1',
DB_PORT: '3307',
DB_DATABASE: 'kt',
DB_USERNAME: 'admin',
DB_SYNC: 'true',
NODE_ENV: 'test',
PORT: '12345',
});
expect(service.readAppProfile()).toEqual({
nodeEnv: 'test',
port: 48085,
});
expect(service.readDatabaseProfile()).toEqual({
host: '127.0.0.1',
port: 3307,
database: 'kt',
username: 'admin',
synchronize: true,
});
});
it('masks secrets in checks and snapshots', () => {
const service = createService({
ADMIN_TOKEN_SECRET: 'abcdef123456',
DB_HOST: 'mysql',
DB_PORT: '3306',
DB_USERNAME: 'root',
DB_PASSWORD: 'password-value',
DB_DATABASE: 'kt',
MINIO_ACCESS_KEY: 'minio-access-key',
MINIO_USE_SSL: 'true',
WORDPRESS_ADMIN_USERNAME: 'wordpress-user',
WORDPRESS_ADMIN_PASSWORD: 'wordpress-password',
LOKI_PASSWORD: 'loki-password',
QQBOT_REVERSE_WS_TOKEN: 'qq-reverse-token',
NAPCAT_WEBUI_TOKEN: 'napcat-webui-token',
});
const snapshot = service.getSafeSnapshot();
const snapshotJson = JSON.stringify(snapshot);
const adminSecretCheck = snapshot.checks.find(
(check) => check.key === 'ADMIN_TOKEN_SECRET',
);
expect(service.maskSecret('abcdef123456')).toBe('ab***56');
expect(service.maskSecret('')).toBe('');
expect(service.maskSecret('abcd')).toBe('****');
expect(adminSecretCheck).toEqual(
expect.objectContaining({
present: true,
maskedValue: 'ab***56',
}),
);
expect(snapshotJson).not.toContain('abcdef123456');
expect(snapshotJson).not.toContain('password-value');
expect(snapshotJson).not.toContain('minio-access-key');
expect(snapshotJson).not.toContain('wordpress-password');
expect(snapshotJson).not.toContain('loki-password');
expect(snapshotJson).not.toContain('qq-reverse-token');
expect(snapshotJson).not.toContain('napcat-webui-token');
expect(snapshot.minio.accessKey).toBe('mi***ey');
expect(snapshot.minio.useSSL).toBe(false);
expect(snapshot.wordpress.adminUsername).toBe('wordpress-user');
expect(snapshot.wordpress.passwordConfigured).toBe(true);
expect(snapshot.loki.passwordConfigured).toBe(true);
expect(snapshot.qqbot.reverseWsToken).toBe('qq***en');
expect(snapshot.qqbot.napcatWebuiToken).toBe('na***en');
});
it('reads current WordPress, Loki, and NapCat runtime keys without leaking secrets', () => {
const service = createService({
WORDPRESS_BASE_URL: 'https://blog.example.test',
WORDPRESS_HOST_HEADER: 'blog.example.test',
WORDPRESS_ADMIN_USERNAME: 'wordpress-admin',
WORDPRESS_ADMIN_PASSWORD: 'wordpress-password',
WORDPRESS_TIMEOUT_MS: '16000',
WORDPRESS_LOGIN_TIMEOUT_MS: '4000',
WORDPRESS_AVAILABILITY_TTL_MS: '70000',
LOKI_URL: 'https://loki-push.example.test',
LOKI_QUERY_HOST: 'https://loki-query.example.test',
LOKI_ENV: 'production',
LOKI_HTTP_REQUEST_PUSH_ENABLED: 'false',
LOKI_USERNAME: 'loki-user',
LOKI_PASSWORD: 'loki-password',
QQBOT_NAPCAT_ROOT: '/vol1/docker/napcat',
QQBOT_NAPCAT_IMAGE: 'mlikiowa/napcat-docker:latest',
QQBOT_NAPCAT_CONTAINER_MODE: 'ssh',
QQBOT_NAPCAT_SSH_TARGET: 'nas',
QQBOT_NAPCAT_SSH_PORT: '2202',
QQBOT_NAPCAT_SSH_KEY_PATH: '/home/kt/.ssh/napcat',
QQBOT_NAPCAT_REVERSE_WS_BASE: 'ws://api.example.test/onebot',
QQBOT_REVERSE_WS_PATH: '/qqbot/reverse',
QQBOT_REVERSE_WS_TOKEN: 'qq-reverse-token',
NAPCAT_WEBUI_BASE_URL: 'http://127.0.0.1:6099',
NAPCAT_WEBUI_TOKEN: 'napcat-webui-token',
});
expect(service.readWordpressProfile()).toEqual({
baseUrl: 'https://blog.example.test',
hostHeader: 'blog.example.test',
adminUsername: 'wordpress-admin',
passwordConfigured: true,
timeoutMs: 16000,
loginTimeoutMs: 4000,
availabilityTtlMs: 70000,
});
expect(service.readLokiProfile()).toEqual({
transportEnabled: true,
httpRequestPushEnabled: false,
queryConfigured: true,
host: 'https://loki-push.example.test',
queryHost: 'https://loki-query.example.test',
environment: 'production',
tenantId: '',
username: 'loki-user',
passwordConfigured: true,
});
expect(service.readQqbotProfile()).toEqual({
reverseWsPath: '/qqbot/reverse',
reverseWsToken: 'qq***en',
napcatRoot: '/vol1/docker/napcat',
napcatImage: 'mlikiowa/napcat-docker:latest',
napcatContainerMode: 'ssh',
napcatSshTarget: 'nas',
napcatSshPort: 2202,
napcatSshKeyPath: '/home/kt/.ssh/napcat',
napcatReverseWsBase: 'ws://api.example.test/onebot',
napcatWebuiBaseUrl: 'http://127.0.0.1:6099',
napcatWebuiToken: 'na***en',
});
const checks = service.getConfigChecks();
expect(checks).toContainEqual(
expect.objectContaining({
key: 'LOKI_HOST|LOKI_URL',
level: 'optional',
present: true,
}),
);
expect(checks).toContainEqual(
expect.objectContaining({
key: 'QQBOT_NAPCAT_IMAGE',
level: 'optional',
present: true,
}),
);
expect(checks).toContainEqual(
expect.objectContaining({
key: 'QQBOT_NAPCAT_REVERSE_WS_URL|QQBOT_NAPCAT_REVERSE_WS_BASE',
level: 'optional',
present: true,
}),
);
expect(checks).toContainEqual(
expect.objectContaining({
key: 'NAPCAT_WEBUI_BASE_URL|QQBOT_NAPCAT_WEBUI_URL',
level: 'optional',
present: true,
}),
);
const snapshotJson = JSON.stringify(service.getSafeSnapshot());
expect(snapshotJson).not.toContain('wordpress-password');
expect(snapshotJson).not.toContain('loki-password');
expect(snapshotJson).not.toContain('qq-reverse-token');
expect(snapshotJson).not.toContain('napcat-webui-token');
});
it('marks missing required config as absent', () => {
const service = createService({
DB_HOST: 'mysql',
});
const checks = service.getConfigChecks();
expect(checks).toContainEqual(
expect.objectContaining({
key: 'DB_PASSWORD',
level: 'required',
present: false,
message: 'DB_PASSWORD is not configured',
}),
);
});
});

View File

@ -1,314 +0,0 @@
import { RuntimeEvidenceService } from '../../src/runtime/evidence/runtime-evidence.service';
describe('RuntimeEvidenceService', () => {
it('creates records with explicit timing, duration, and schema version', () => {
const service = new RuntimeEvidenceService();
const startedAt = new Date('2026-06-13T00:00:00.000Z');
const endedAt = new Date('2026-06-13T00:00:01.250Z');
const record = service.createRecord({
title: 'runtime config smoke',
taskType: 'backend',
project: 'kt-template-online-api',
environment: 'local',
operation: 'runtime-config',
status: 'passed',
startedAt,
endedAt,
});
expect(record.startedAt).toBe(startedAt);
expect(record.endedAt).toBe(endedAt);
expect(record.durationMs).toBe(1250);
expect(record.schemaVersion).toBe(1);
});
it('keeps duration non-negative when evidence ends before it starts', () => {
const service = new RuntimeEvidenceService();
const startedAt = new Date('2026-06-13T00:00:01.250Z');
const endedAt = new Date('2026-06-13T00:00:00.000Z');
const record = service.createRecord({
title: 'runtime config smoke',
taskType: 'backend',
project: 'kt-template-online-api',
environment: 'local',
operation: 'runtime-config',
status: 'failed',
startedAt,
endedAt,
});
expect(record.durationMs).toBe(0);
});
it('redacts nested sensitive fields in details and cleanup details', () => {
const service = new RuntimeEvidenceService();
const record = service.createRecord({
title: 'qqbot captcha smoke',
taskType: 'api',
project: 'kt-template-online-api',
environment: 'local',
operation: 'qqbot-login',
target: 'account:2637330537',
status: 'blocked',
details: {
username: 'kept-user',
password: 1,
nested: {
token: true,
message: 'kept-message',
replyText: 'raw-reply-text',
},
},
cleanup: {
status: 'failed',
message: 'cleanup failed',
details: {
ticket: 'raw-ticket',
randstr: 'raw-randstr',
removed: false,
},
},
});
expect(record.details).toEqual({
username: 'kept-user',
password: '<redacted>',
nested: {
token: '<redacted>',
message: 'kept-message',
replyText: '<redacted>',
},
});
expect(record.cleanup?.details).toEqual({
ticket: '<redacted>',
randstr: '<redacted>',
removed: false,
});
});
it('sanitizes arrays and case-insensitive sensitive keys while preserving dates', () => {
const service = new RuntimeEvidenceService();
const createdAt = new Date('2026-06-13T00:00:00.000Z');
const record = service.createRecord({
title: 'array evidence',
taskType: 'backend',
project: 'kt-template-online-api',
environment: 'local',
operation: 'runtime-evidence',
status: 'failed',
details: {
events: [
{ Authorization: 'Bearer raw', name: 'kept' },
{ nested: { Cookie: 'session=raw', count: 2 } },
],
createdAt,
},
});
const details = record.details as {
events: Array<Record<string, unknown>>;
createdAt: Date;
};
expect(details.createdAt).toBe(createdAt);
expect(details.createdAt).toBeInstanceOf(Date);
expect(details.events).toHaveLength(2);
expect(details.events[0]).toEqual({
Authorization: '<redacted>',
name: 'kept',
});
expect(details.events[1]).toEqual({
nested: {
Cookie: '<redacted>',
count: 2,
},
});
});
it('sanitizes sensitive key-value text across the whole evidence record without mutating input', () => {
const service = new RuntimeEvidenceService();
const input = {
title: 'safe title token=raw-token',
taskType: 'api',
project: 'kt-template-online-api',
environment: 'local',
operation: 'qqbot-login',
target: 'account safe text Authorization=Bearer raw-token',
status: 'failed' as const,
error: {
category: 'operation_failed' as const,
operation: 'captcha',
message: 'safe error message ticket=raw-ticket',
cause:
'safe cause randstr=raw-randstr Authorization: Bearer raw-token Cookie=session=raw-cookie',
retryable: false,
},
assertions: [
{
name: 'reply text check',
passed: false,
message: 'safe assertion replyText=raw-reply-text',
},
],
cleanup: {
status: 'failed' as const,
message: 'safe cleanup Cookie=session=raw-cookie',
},
};
const record = service.createRecord(input);
const serialized = JSON.stringify(record);
expect(serialized).not.toContain('raw-ticket');
expect(serialized).not.toContain('raw-randstr');
expect(serialized).not.toContain('raw-token');
expect(serialized).not.toContain('raw-cookie');
expect(serialized).not.toContain('raw-reply-text');
expect(serialized).toContain('safe title');
expect(serialized).toContain('safe error message');
expect(serialized).toContain('safe cause');
expect(serialized).toContain('safe assertion');
expect(serialized).toContain('safe cleanup');
expect(input.title).toContain('raw-token');
expect(input.error.message).toContain('raw-ticket');
expect(input.error.cause).toContain('raw-randstr');
expect(input.assertions[0].message).toContain('raw-reply-text');
expect(input.cleanup.message).toContain('raw-cookie');
});
it('redacts captcha sid, snake_case secret keys, JSON secret text, and base64 payloads', () => {
const service = new RuntimeEvidenceService();
const rawBase64Payload = `data:image/png;base64,${'A'.repeat(160)}`;
const record = service.createRecord({
title: 'captcha evidence',
taskType: 'api',
project: 'kt-template-online-api',
environment: 'local',
operation: 'qqbot-login',
status: 'blocked',
details: {
sid: 'raw-captcha-sid',
private_key: 'raw-private-key',
ssh_key: 'raw-ssh-key',
imageBase64: rawBase64Payload,
text: 'sid=raw-text-sid private_key=raw-text-private-key ssh_key=raw-text-ssh-key',
jsonText:
'{"sid":"raw-json-sid","token":"raw-json-token","safe":"kept"}',
},
});
const serialized = JSON.stringify(record);
expect(serialized).not.toContain('raw-captcha-sid');
expect(serialized).not.toContain('raw-private-key');
expect(serialized).not.toContain('raw-ssh-key');
expect(serialized).not.toContain('raw-text-sid');
expect(serialized).not.toContain('raw-text-private-key');
expect(serialized).not.toContain('raw-text-ssh-key');
expect(serialized).not.toContain('raw-json-sid');
expect(serialized).not.toContain('raw-json-token');
expect(serialized).not.toContain(rawBase64Payload);
expect(serialized).toContain('captcha evidence');
expect(serialized).toContain('kept');
});
it('redacts compound token and secret text keys while preserving session identifiers', () => {
const service = new RuntimeEvidenceService();
const record = service.createRecord({
title: 'compound token evidence',
taskType: 'api',
project: 'kt-template-online-api',
environment: 'local',
operation: 'admin-login',
target: 'sessionId=KT_SCAN_SAFE',
status: 'failed',
details: {
sessionId: 'KT_SCAN_SAFE',
text:
'accessToken=raw-access-token refreshToken=raw-refresh-token access_token=raw-snake-token client_secret=raw-client-secret sessionId=KT_SCAN_SAFE',
jsonText:
'{"accessToken":"raw-json-access","refreshToken":"raw-json-refresh","access_token":"raw-json-snake","client_secret":"raw-json-client-secret","sessionId":"KT_SCAN_SAFE"}',
},
});
const serialized = JSON.stringify(record);
expect(serialized).not.toContain('raw-access-token');
expect(serialized).not.toContain('raw-refresh-token');
expect(serialized).not.toContain('raw-snake-token');
expect(serialized).not.toContain('raw-client-secret');
expect(serialized).not.toContain('raw-json-access');
expect(serialized).not.toContain('raw-json-refresh');
expect(serialized).not.toContain('raw-json-snake');
expect(serialized).not.toContain('raw-json-client-secret');
expect(serialized).toContain('KT_SCAN_SAFE');
});
it('redacts cookie headers, quoted private key values, and non-string JSON secrets', () => {
const service = new RuntimeEvidenceService();
const record = service.createRecord({
title: 'realistic secret text evidence',
taskType: 'api',
project: 'kt-template-online-api',
environment: 'local',
operation: 'runtime-evidence',
status: 'failed',
details: {
cookieHeader:
'Cookie: admin_access_token=raw-cookie-token; wordpress_logged_in=raw-wordpress-cookie; theme=light',
quotedPrivateKey:
'private_key="-----BEGIN PRIVATE KEY----- raw pem body -----END PRIVATE KEY-----"',
jsonText:
'{"accessToken":12345,"sid":67890,"client_secret":true,"sessionId":"KT_SCAN_SAFE"}',
},
});
const serialized = JSON.stringify(record);
expect(serialized).not.toContain('raw-cookie-token');
expect(serialized).not.toContain('raw-wordpress-cookie');
expect(serialized).not.toContain('raw pem body');
expect(serialized).not.toContain('12345');
expect(serialized).not.toContain('67890');
expect(serialized).toContain('KT_SCAN_SAFE');
});
it('redacts access key and api key text plus unquoted multi-word secret values', () => {
const service = new RuntimeEvidenceService();
const record = service.createRecord({
title: 'access key evidence',
taskType: 'backend',
project: 'kt-template-online-api',
environment: 'local',
operation: 'runtime-evidence',
status: 'failed',
details: {
text:
'accessKey=raw-access-key access_key=raw-snake-access-key apiKey=raw-api-key api_key=raw-snake-api-key private_key=-----BEGIN PRIVATE KEY----- raw unquoted pem token=Bearer raw-bearer-token safe=value',
jsonText:
'{"accessKey":"raw-json-access-key","api_key":"raw-json-api-key","safe":"kept"}',
},
});
const serialized = JSON.stringify(record);
expect(serialized).not.toContain('raw-access-key');
expect(serialized).not.toContain('raw-snake-access-key');
expect(serialized).not.toContain('raw-api-key');
expect(serialized).not.toContain('raw-snake-api-key');
expect(serialized).not.toContain('raw unquoted pem');
expect(serialized).not.toContain('raw-bearer-token');
expect(serialized).not.toContain('raw-json-access-key');
expect(serialized).not.toContain('raw-json-api-key');
expect(serialized).toContain('safe=value');
expect(serialized).toContain('kept');
});
});

View File

@ -1,18 +0,0 @@
import { RuntimeHealthController } from '../../src/runtime/health/runtime-health.controller';
import type { RuntimeHealthReport } from '../../src/runtime/health/runtime-health.types';
describe('RuntimeHealthController', () => {
it('returns the service report and calls the service once', () => {
const report: RuntimeHealthReport = {
service: 'kt-template-online-api',
checkedAt: '2026-06-13T00:00:00.000Z',
status: 'ready',
checks: [],
};
const service = { getRuntimeHealth: jest.fn(() => report) };
const controller = new RuntimeHealthController(service as any);
expect(controller.getRuntimeHealth()).toBe(report);
expect(service.getRuntimeHealth).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,162 +0,0 @@
import { RuntimeHealthService } from '../../src/runtime/health/runtime-health.service';
import type { RuntimeSafeConfigSnapshot } from '../../src/runtime/config/runtime-config.types';
function createSnapshot(
checks: RuntimeSafeConfigSnapshot['checks'],
): RuntimeSafeConfigSnapshot {
return {
app: { nodeEnv: 'test', port: 48085 },
database: {
host: 'mysql',
port: 3306,
database: 'kt',
username: 'root',
synchronize: false,
},
loki: {
transportEnabled: true,
httpRequestPushEnabled: true,
queryConfigured: true,
host: 'https://loki-push.example.test',
queryHost: 'https://loki-query.example.test',
environment: 'test',
tenantId: 'kt',
username: 'loki-user',
passwordConfigured: true,
},
minio: {
endpoint: 'minio',
port: 9000,
useSSL: false,
accessKey: 'mi***ey',
bucket: 'kt-template-online',
},
wordpress: {
baseUrl: 'https://blog.example.test',
hostHeader: 'blog.example.test',
adminUsername: 'wordpress-admin',
passwordConfigured: true,
timeoutMs: 15000,
loginTimeoutMs: 3000,
availabilityTtlMs: 60000,
},
qqbot: {
reverseWsPath: '/qqbot/onebot/reverse',
reverseWsToken: 'qq***en',
napcatRoot: '/vol1/docker/napcat',
napcatImage: 'mlikiowa/napcat-docker:latest',
napcatContainerMode: 'ssh',
napcatSshTarget: 'nas',
napcatSshPort: 2202,
napcatSshKeyPath: '/home/kt/.ssh/napcat',
napcatReverseWsBase: 'ws://api.example.test/onebot',
napcatWebuiBaseUrl: 'http://127.0.0.1:6099',
napcatWebuiToken: 'na***en',
},
checks,
};
}
function createService(snapshot: RuntimeSafeConfigSnapshot) {
return new RuntimeHealthService({
getSafeSnapshot: jest.fn(() => snapshot),
} as any);
}
describe('RuntimeHealthService', () => {
it('returns ready when required and optional checks are present', () => {
const service = createService(
createSnapshot([
{ key: 'DB_HOST', level: 'required', present: true },
{ key: 'MINIO_ENDPOINT', level: 'optional', present: true },
]),
);
const report = service.getRuntimeHealth();
expect(report).toEqual(
expect.objectContaining({
service: 'kt-template-online-api',
status: 'ready',
}),
);
expect(report).not.toHaveProperty('config');
expect(JSON.stringify(report)).not.toContain('mysql');
expect(JSON.stringify(report)).not.toContain('/vol1/docker/napcat');
expect(new Date(report.checkedAt).toISOString()).toBe(report.checkedAt);
expect(report.checks).toContainEqual({
name: 'process',
status: 'live',
critical: true,
message: 'NestJS process answered runtime health request',
});
expect(report.checks).toContainEqual(
expect.objectContaining({
name: 'config:DB_HOST',
status: 'ready',
critical: true,
message: 'DB_HOST is configured',
}),
);
expect(report.checks).toContainEqual(
expect.objectContaining({
name: 'config:MINIO_ENDPOINT',
status: 'ready',
critical: false,
message: 'MINIO_ENDPOINT is configured',
}),
);
});
it('returns blocked when required config is missing', () => {
const service = createService(
createSnapshot([
{
key: 'DB_PASSWORD',
level: 'required',
present: false,
message: 'DB_PASSWORD is not configured',
},
{ key: 'MINIO_ENDPOINT', level: 'optional', present: true },
]),
);
const report = service.getRuntimeHealth();
expect(report.status).toBe('blocked');
expect(report.checks).toContainEqual(
expect.objectContaining({
name: 'config:DB_PASSWORD',
status: 'blocked',
critical: true,
message: 'DB_PASSWORD is not configured',
}),
);
});
it('returns degraded when only optional config is missing', () => {
const service = createService(
createSnapshot([
{ key: 'DB_HOST', level: 'required', present: true },
{
key: 'LOKI_PASSWORD',
level: 'optional',
present: false,
message: 'LOKI_PASSWORD is not configured',
},
]),
);
const report = service.getRuntimeHealth();
expect(report.status).toBe('degraded');
expect(report.checks).toContainEqual(
expect.objectContaining({
name: 'config:LOKI_PASSWORD',
status: 'degraded',
critical: false,
message: 'LOKI_PASSWORD is not configured',
}),
);
});
});