Compare commits
7 Commits
2a890d894d
...
eaf22706d8
| Author | SHA1 | Date | |
|---|---|---|---|
| eaf22706d8 | |||
| 674d238f6f | |||
| 6b8b76b6ff | |||
| b9acee5af0 | |||
| a41a945656 | |||
| ef7131c15b | |||
| bb76476213 |
@ -4,6 +4,7 @@ DB_USERNAME=root
|
|||||||
DB_PASSWORD=
|
DB_PASSWORD=
|
||||||
DB_DATABASE=shy_template
|
DB_DATABASE=shy_template
|
||||||
DB_SYNC=true
|
DB_SYNC=true
|
||||||
|
DB_TIMEZONE=+08:00
|
||||||
|
|
||||||
MINIO_ENDPOINT=localhost
|
MINIO_ENDPOINT=localhost
|
||||||
MINIO_PORT=9000
|
MINIO_PORT=9000
|
||||||
|
|||||||
2
API.md
2
API.md
@ -352,7 +352,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
|
|||||||
|
|
||||||
同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器,后端会释放旧绑定和未共享的旧容器,避免同账号多实例互相挤下线。OneBot notice 只有机器人下线、登录失效、`KickedOffLine` 等账号级信号才会记录 QQ 登录态异常并生成 `qqbot.account.offline` 站内信,普通群成员 kick 不属于账号离线信号。下线原因写入 `lastError` 前按 `last_error` 500 字符列宽截断;后续无错误的普通断连只更新 OneBot 连接状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于 QQ 登录态离线信号;心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态。托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像。
|
同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器,后端会释放旧绑定和未共享的旧容器,避免同账号多实例互相挤下线。OneBot notice 只有机器人下线、登录失效、`KickedOffLine` 等账号级信号才会记录 QQ 登录态异常并生成 `qqbot.account.offline` 站内信,普通群成员 kick 不属于账号离线信号。下线原因写入 `lastError` 前按 `last_error` 500 字符列宽截断;后续无错误的普通断连只更新 OneBot 连接状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于 QQ 登录态离线信号;心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态。托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像。
|
||||||
|
|
||||||
托管 NapCat 容器按账号持久化设备身份,`napcat_device_identity` 保存账号对应的数据目录、hostname、machine-id 路径、MAC 地址、验证状态和最近登录证据。重建同一账号容器时会复用这些设备字段,并在 Docker run 中注入 `--hostname`、`--mac-address` 和只读 `/etc/machine-id` 挂载,降低每次重建都被 QQ 判定为新设备的概率。
|
托管 NapCat 容器按账号持久化设备身份,`napcat_device_identity` 保存账号对应的数据目录、hostname、machine-id 路径、MAC 地址、验证状态和最近登录证据。重建同一账号容器时会复用 `pc-<8hex>` hostname、`02:42:*` MAC 和 machine-id,并在 Docker run 中注入 `--hostname`、`--mac-address` 和只读 `/etc/machine-id` 挂载;后端还会同步写入 QQNT Linux `machine-info`,让 QQNT 计算 GUID 时使用的 MAC 与 Docker 网卡一致。当前策略名为 `qqnt-visible-hostname-v1` / `docker-bridge-mac-v1`,绑定关系会回填 `napcat_account_binding.device_identity_id`。
|
||||||
|
|
||||||
### NapCat Runtime Profile
|
### NapCat Runtime Profile
|
||||||
|
|
||||||
|
|||||||
@ -163,7 +163,7 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow
|
|||||||
- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。
|
- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。
|
||||||
- NapCat 账号新增/编辑支持可选 QQ 登录密码:Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。
|
- NapCat 账号新增/编辑支持可选 QQ 登录密码:Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。
|
||||||
- NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。`ACCOUNT` 只负责指定快速登录账号;如果数据卷内没有该 QQ 历史登录记录,后端会跳过快速登录并优先尝试账号保存的登录密码,密码登录会按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果,准备中的扫码会话会续期,避免后台密码登录未结束时前端先过期;如果 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码登录等待窗口加缓冲)后会自动恢复为普通状态检测,不会永久卡在“正在尝试密码登录”。如果 NapCat 返回 `proofWaterUrl` 或日志出现“需要验证码”,会保持会话 pending 并把腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`,不是让用户只打开外链;状态轮询遇到验证码文案但缺少 URL 时,会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底;密码登录成功后会移除运行态 `NAPCAT_QUICK_PASSWORD`,清理失败则本次登录失败,避免成功态残留明文 env;密码登录也失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时才重置登录态并生成二维码。Admin「更新登录」的 SSE 步骤顺序按实际路径为 `quick-login-*`(已有历史会话)-> `password-login-*` / `password-login-captcha` -> `password-env-cleanup` -> `relogin-reset/qrcode/waiting-scan`;SSE 事件缓存因 Pod 重启丢失时,新订阅会收到当前会话快照。
|
- NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。`ACCOUNT` 只负责指定快速登录账号;如果数据卷内没有该 QQ 历史登录记录,后端会跳过快速登录并优先尝试账号保存的登录密码,密码登录会按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果,准备中的扫码会话会续期,避免后台密码登录未结束时前端先过期;如果 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码登录等待窗口加缓冲)后会自动恢复为普通状态检测,不会永久卡在“正在尝试密码登录”。如果 NapCat 返回 `proofWaterUrl` 或日志出现“需要验证码”,会保持会话 pending 并把腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`,不是让用户只打开外链;状态轮询遇到验证码文案但缺少 URL 时,会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底;密码登录成功后会移除运行态 `NAPCAT_QUICK_PASSWORD`,清理失败则本次登录失败,避免成功态残留明文 env;密码登录也失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时才重置登录态并生成二维码。Admin「更新登录」的 SSE 步骤顺序按实际路径为 `quick-login-*`(已有历史会话)-> `password-login-*` / `password-login-captcha` -> `password-env-cleanup` -> `relogin-reset/qrcode/waiting-scan`;SSE 事件缓存因 Pod 重启丢失时,新订阅会收到当前会话快照。
|
||||||
- NapCat 设备身份按账号持久化到 `napcat_device_identity`:同一账号重建容器会复用数据目录、hostname、machine-id 和 MAC,并在 Docker run 中注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id`,避免频繁重建被 QQ 判定为全新设备。
|
- NapCat 设备身份按账号持久化到 `napcat_device_identity`:同一账号重建容器会复用数据目录、`pc-<8hex>` hostname、machine-id 和 `02:42:*` MAC;Docker run 会注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id`,并同步写入 QQNT Linux `machine-info`,使 `/etc/machine-id`、Docker MAC 和 QQNT 本地设备缓存保持一致。当前策略名为 `qqnt-visible-hostname-v1` / `docker-bridge-mac-v1`。
|
||||||
- NapCat 新设备验证走同一 scan session:`CaptchaLogin` 返回 `needNewDevice` 后,后端继续调用 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`,Admin/SSE 分开展示 `captchaUrl`、`newDeviceQrcode`、已扫码、确认中、验证成功、登录成功/失败等中文进度,不把 `jumpUrl` 当作唯一完成入口。
|
- NapCat 新设备验证走同一 scan session:`CaptchaLogin` 返回 `needNewDevice` 后,后端继续调用 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`,Admin/SSE 分开展示 `captchaUrl`、`newDeviceQrcode`、已扫码、确认中、验证成功、登录成功/失败等中文进度,不把 `jumpUrl` 当作唯一完成入口。
|
||||||
- NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000`,`QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后先尝试 `ACCOUNT` 历史会话快速登录,再尝试账号保存的登录密码,仍失败时写入离线原因并复用 `super` 站内信告警;看门狗不自动进入扫码阶段。
|
- NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000`,`QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后先尝试 `ACCOUNT` 历史会话快速登录,再尝试账号保存的登录密码,仍失败时写入离线原因并复用 `super` 站内信告警;看门狗不自动进入扫码阶段。
|
||||||
- BangDream 当前源码根目录是 `src/modules/qqbot/plugins/bangdream/src`;按第三期插件结构放置真实职责代码:业务在 `domain/*`,编排在 `application`,操作在 `operations`,外部 API 在 `infrastructure/integration`,缓存/静态修正在 `infrastructure/storage`,字典和静态配置在 `config`,视觉渲染公共件在 `theme`;不要恢复旧 `tsugu` 层级、旧大桶目录、纯 re-export 转接文件或空 `.gitkeep` 目录壳。
|
- BangDream 当前源码根目录是 `src/modules/qqbot/plugins/bangdream/src`;按第三期插件结构放置真实职责代码:业务在 `domain/*`,编排在 `application`,操作在 `operations`,外部 API 在 `infrastructure/integration`,缓存/静态修正在 `infrastructure/storage`,字典和静态配置在 `config`,视觉渲染公共件在 `theme`;不要恢复旧 `tsugu` 层级、旧大桶目录、纯 re-export 转接文件或空 `.gitkeep` 目录壳。
|
||||||
|
|||||||
@ -0,0 +1,807 @@
|
|||||||
|
# Admin Environment Dashboard Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 将 Admin `/dashboard/analytics` 改造成多站点环境状态总览总控面板,覆盖本机开发、NAS 线上、腾讯云、r4se 远程环境,并明确展示未接入证据,第一版只做观测与只读自检,不提供高风险写操作。
|
||||||
|
|
||||||
|
**Architecture:** 后端在 API `admin/platform-config` 下新增环境面板聚合模块,以 `Site -> Node -> Service -> Signal` 为统一模型;HTTP dashboard/self-check 负责状态快照和只读兜底,平台级 EnvironmentEventBus 通过 local/mqtt 模式收口 topic 事件并驱动 recent events 与 cache invalidation,API SSE stream 把脱敏后的增量事件推送给 Admin;内部服务信号从现有 Nest 服务读取,远程信号通过只读适配器读取 Jenkins、Kubernetes、Tencent Cloud、Caddy、WireGuard、OpenClash/Mihomo;前端保留 Vben Dashboard 路由,替换为 A 方案布局:顶部状态条、左侧站点栏、中间拓扑、右侧证据/动作抽屉、底部事件流。Admin 不跑轮询或定时刷新。
|
||||||
|
|
||||||
|
**Tech Stack:** NestJS、TypeScript、TypeORM、axios、mqtt、tencentcloud-sdk-nodejs、Vben Admin、Vue 3、antdv-next、Vitest/Jest、Playwright 轻量页面烟测。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Source Spec
|
||||||
|
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\docs\superpowers\specs\2026-06-18-admin-environment-dashboard-design.md`
|
||||||
|
- 当前确认的页面方向:方案 A `Site Command Center`
|
||||||
|
- 第一版能力边界:观测与只读自检;重启、部署、迁移、重建容器、插件启停、任务立即执行、Caddy/OpenClash 改配置等动作必须展示为禁用。
|
||||||
|
|
||||||
|
## File Structure Map
|
||||||
|
|
||||||
|
### API Repo
|
||||||
|
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\domain\environment-dashboard.types.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\application\environment-dashboard-status.mapper.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\application\environment-dashboard-action.catalog.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\application\environment-event.materializer.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\application\environment-dashboard.service.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\application\environment-dashboard-self-check.service.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\application\environment-event-stream.service.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\environment-dashboard-config.service.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\environment-dashboard-cache.service.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\environment-dashboard-evidence.mapper.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\event\environment-event-bus.service.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\event\environment-mqtt-topic.catalog.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\event\qqbot-environment-event.bridge.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\adapters\environment-readonly-http.client.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\adapters\jenkins-readonly.adapter.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\adapters\kubernetes-readonly.adapter.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\adapters\tencent-cloud-readonly.adapter.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\adapters\caddy-readonly.adapter.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\adapters\wireguard-readonly.adapter.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\adapters\mihomo-readonly.adapter.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\collectors\local-dev-signal.collector.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\infrastructure\collectors\nas-prod-signal.collector.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\presentation\environment-dashboard.controller.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\environment-dashboard\presentation\dto\environment-dashboard.dto.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\admin\platform-config\admin-platform-config.module.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\qqbot\core\qqbot-core.module.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\qqbot\plugin-platform\qqbot-plugin-platform.module.ts`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\.env.example`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\README.md`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\API.md`
|
||||||
|
- `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\admin\environment-dashboard\*.spec.ts`
|
||||||
|
|
||||||
|
### Admin Repo
|
||||||
|
|
||||||
|
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\api\system\environment.ts`
|
||||||
|
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\dashboard\analytics\index.vue`
|
||||||
|
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\dashboard\analytics\components\EnvironmentStatusBar.vue`
|
||||||
|
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\dashboard\analytics\components\EnvironmentSiteRail.vue`
|
||||||
|
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\dashboard\analytics\components\EnvironmentTopology.vue`
|
||||||
|
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\dashboard\analytics\components\EnvironmentEvidencePanel.vue`
|
||||||
|
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\dashboard\analytics\components\EnvironmentEventStream.vue`
|
||||||
|
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\dashboard\analytics\composables\useEnvironmentDashboardStream.ts`
|
||||||
|
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\dashboard\analytics\types.ts`
|
||||||
|
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\dashboard\analytics\environment-dashboard.spec.tsx`
|
||||||
|
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\api\system\environment.spec.ts`
|
||||||
|
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\locales\langs\zh-CN\page.json`
|
||||||
|
- `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\locales\langs\en-US\page.json`
|
||||||
|
|
||||||
|
## Data Contract
|
||||||
|
|
||||||
|
The API response uses stable IDs so Admin can preserve selection across refreshes.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type EnvironmentHealthStatus =
|
||||||
|
| 'ok'
|
||||||
|
| 'degraded'
|
||||||
|
| 'down'
|
||||||
|
| 'blocked'
|
||||||
|
| 'isolated'
|
||||||
|
| 'unknown'
|
||||||
|
| 'unwired';
|
||||||
|
|
||||||
|
export type EnvironmentSiteStatus = 'online' | 'degraded' | 'isolated' | 'unknown';
|
||||||
|
|
||||||
|
export type EnvironmentSignalSourceKind =
|
||||||
|
| 'live'
|
||||||
|
| 'cached'
|
||||||
|
| 'derived'
|
||||||
|
| 'configured'
|
||||||
|
| 'external-link'
|
||||||
|
| 'unwired';
|
||||||
|
|
||||||
|
export interface EnvironmentDashboardResponse {
|
||||||
|
generatedAt: string;
|
||||||
|
refreshedAt: string;
|
||||||
|
summary: EnvironmentDashboardSummary;
|
||||||
|
sites: EnvironmentSite[];
|
||||||
|
topology: EnvironmentTopology;
|
||||||
|
actions: EnvironmentAction[];
|
||||||
|
events: EnvironmentEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvironmentEventEnvelope {
|
||||||
|
eventId: string;
|
||||||
|
topic: string;
|
||||||
|
siteId: string;
|
||||||
|
nodeId?: string;
|
||||||
|
serviceId?: string;
|
||||||
|
signalId?: string;
|
||||||
|
severity: EnvironmentHealthStatus;
|
||||||
|
sourceKind: 'local' | 'mqtt' | EnvironmentSignalSourceKind;
|
||||||
|
observedAt: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
retained?: boolean;
|
||||||
|
summary: string;
|
||||||
|
evidence?: EnvironmentEvidence[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnvironmentStreamEventType =
|
||||||
|
| 'environment-event'
|
||||||
|
| 'environment-signal'
|
||||||
|
| 'snapshot-required'
|
||||||
|
| 'heartbeat'
|
||||||
|
| 'error';
|
||||||
|
```
|
||||||
|
|
||||||
|
Severity aggregation order is fixed:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const severityWeight: Record<EnvironmentHealthStatus, number> = {
|
||||||
|
ok: 0,
|
||||||
|
unwired: 1,
|
||||||
|
unknown: 1,
|
||||||
|
degraded: 2,
|
||||||
|
isolated: 3,
|
||||||
|
down: 4,
|
||||||
|
blocked: 5,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Execution Rules
|
||||||
|
|
||||||
|
- Work on development branches in each touched repo; do not create `.worktree` directories.
|
||||||
|
- No backend `.env.development`, `.env.production`, real token, SSH key, cloud secret, Jenkins token, kube token, or database password may be committed.
|
||||||
|
- Every new or touched function/method/hook/event handler/exported arrow function must include JSDoc explaining purpose, parameter origin, return semantics, and side effects when present.
|
||||||
|
- High-risk actions are represented by disabled action records with `enabled: false` and `disabledReason`; controller must reject execution paths because this plan does not add a generic write-action endpoint.
|
||||||
|
- `unwired` and `unknown` are valid product states. A missing integration must be visible evidence, not a green health badge.
|
||||||
|
- MQTT is an event layer, not the only source of truth. Retained messages require `observedAt` and `expiresAt`; expired retained messages must become `unknown` or `unwired`.
|
||||||
|
- Admin must not connect to MQTT directly. Broker credentials and internal topics stay inside API runtime.
|
||||||
|
- Admin must not poll or use timer-based dashboard refresh. After the first snapshot, fresh state arrives through SSE; only user actions and `snapshot-required` may trigger another dashboard request.
|
||||||
|
- For interface changes, verify through a real local HTTP request against the Nest app or an already running local service.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 0: Baseline Branches And Dependency Check
|
||||||
|
|
||||||
|
- [ ] In API repo, confirm clean state:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||||
|
git status --short
|
||||||
|
git rev-parse --abbrev-ref HEAD
|
||||||
|
```
|
||||||
|
Expected output: no unstaged lines; branch is `main` or an existing development branch chosen by the user.
|
||||||
|
|
||||||
|
- [ ] Create or switch API development branch:
|
||||||
|
```powershell
|
||||||
|
git switch -c codex/admin-environment-dashboard
|
||||||
|
```
|
||||||
|
Expected output: `Switched to a new branch 'codex/admin-environment-dashboard'`.
|
||||||
|
|
||||||
|
- [ ] In Admin repo, confirm clean state and create branch:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT\Vue\kt-template-admin
|
||||||
|
git status --short
|
||||||
|
git switch -c codex/admin-environment-dashboard
|
||||||
|
```
|
||||||
|
Expected output: no unstaged lines, then new branch message.
|
||||||
|
|
||||||
|
- [ ] Confirm package manager and engine gates:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||||
|
node -v
|
||||||
|
pnpm -v
|
||||||
|
pnpm pkg get packageManager
|
||||||
|
pnpm pkg get engines
|
||||||
|
```
|
||||||
|
Expected output: current Node and pnpm satisfy `engines`; if not, run `nvm ls` before changing version.
|
||||||
|
|
||||||
|
- [ ] Install Tencent Cloud official SDK in API repo only when it is not already present:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||||
|
pnpm add tencentcloud-sdk-nodejs
|
||||||
|
```
|
||||||
|
Expected output: dependency added to `package.json` and lockfile. No credential values are written.
|
||||||
|
|
||||||
|
- [ ] Confirm the existing MQTT client dependency before adding any event-bus code:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||||
|
rg -n '"mqtt"' package.json pnpm-lock.yaml
|
||||||
|
```
|
||||||
|
Expected output: `mqtt` is already present in `package.json`; do not add a second broker/client package.
|
||||||
|
|
||||||
|
## Task 1: API Contract, Status Mapper, And RED Tests
|
||||||
|
|
||||||
|
- [ ] Create `environment-dashboard.types.ts` with domain interfaces for site/node/service/signal/action/event/topology. Include status unions from the spec and source metadata:
|
||||||
|
```ts
|
||||||
|
export interface EnvironmentSignal {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
status: EnvironmentHealthStatus;
|
||||||
|
sourceKind: EnvironmentSignalSourceKind;
|
||||||
|
summary: string;
|
||||||
|
evidence: EnvironmentEvidence[];
|
||||||
|
observedAt?: string;
|
||||||
|
staleAfterSeconds?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Create `environment-dashboard-status.mapper.ts` with documented helpers:
|
||||||
|
- `pickWorstHealthStatus(statuses)`
|
||||||
|
- `mapSiteStatus(statuses)`
|
||||||
|
- `countSignals(sites)`
|
||||||
|
- `normalizeObservedAt(dateLike)`
|
||||||
|
|
||||||
|
- [ ] Add RED test `test/modules/admin/environment-dashboard/environment-dashboard-status.spec.ts`:
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
mapSiteStatus,
|
||||||
|
pickWorstHealthStatus,
|
||||||
|
} from '../../../../src/modules/admin/platform-config/environment-dashboard/application/environment-dashboard-status.mapper';
|
||||||
|
|
||||||
|
describe('environment dashboard status mapper', () => {
|
||||||
|
it('uses blocked as the strongest signal', () => {
|
||||||
|
expect(pickWorstHealthStatus(['ok', 'down', 'blocked'])).toBe('blocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps unwired integrations visible without marking a site healthy', () => {
|
||||||
|
expect(mapSiteStatus(['ok', 'unwired'])).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks isolated remote sites separately from ordinary degradation', () => {
|
||||||
|
expect(mapSiteStatus(['ok', 'isolated'])).toBe('isolated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Run RED:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||||
|
pnpm exec jest --runTestsByPath test/modules/admin/environment-dashboard/environment-dashboard-status.spec.ts --runInBand
|
||||||
|
```
|
||||||
|
Expected output: fails before mapper implementation is complete because module or exported functions are missing.
|
||||||
|
|
||||||
|
- [ ] Implement mapper and rerun the same command.
|
||||||
|
Expected output: test suite passes.
|
||||||
|
|
||||||
|
## Task 2: API Action Catalog And Disabled Write Boundaries
|
||||||
|
|
||||||
|
- [ ] Create `environment-dashboard-action.catalog.ts`. It must return all supported visible actions:
|
||||||
|
- enabled readonly: `refresh-dashboard`, `run-self-check`, `open-runtime-logs`, `open-service-route`
|
||||||
|
- disabled high-risk: `restart-api-pod`, `trigger-jenkins-deploy`, `run-db-migration`, `recreate-napcat-container`, `toggle-plugin`, `run-plugin-task-now`, `create-minio-bucket`, `wordpress-import`, `reload-caddy`, `switch-openclash`, `restart-tencent-cvm`, `modify-wireguard-peer`
|
||||||
|
|
||||||
|
- [ ] Add `test/modules/admin/environment-dashboard/environment-dashboard-action.catalog.spec.ts` asserting disabled actions are present and all write actions have `enabled: false`.
|
||||||
|
|
||||||
|
- [ ] Run RED, implement, rerun:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||||
|
pnpm exec jest --runTestsByPath test/modules/admin/environment-dashboard/environment-dashboard-action.catalog.spec.ts --runInBand
|
||||||
|
```
|
||||||
|
Expected output after implementation: all action catalog tests pass.
|
||||||
|
|
||||||
|
## Task 3: API Evidence Helpers And Readonly HTTP Client
|
||||||
|
|
||||||
|
- [ ] Create `environment-dashboard-evidence.mapper.ts` with functions:
|
||||||
|
- `liveEvidence(source, summary, observedAt, metadata)`
|
||||||
|
- `unwiredEvidence(source, missingConfigKeys, documentationPath)`
|
||||||
|
- `errorEvidence(source, error, observedAt)`
|
||||||
|
- `cachedEvidence(source, summary, observedAt, expiresAt)`
|
||||||
|
|
||||||
|
- [ ] Create `environment-readonly-http.client.ts` wrapping axios with:
|
||||||
|
- GET and HEAD only
|
||||||
|
- timeout from environment dashboard config
|
||||||
|
- response body truncation before evidence storage
|
||||||
|
- secret-safe header handling
|
||||||
|
|
||||||
|
- [ ] Add tests:
|
||||||
|
- `environment-dashboard-evidence.mapper.spec.ts`
|
||||||
|
- `environment-readonly-http.client.spec.ts`
|
||||||
|
|
||||||
|
- [ ] Run targeted tests:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||||
|
pnpm exec jest --runTestsByPath test/modules/admin/environment-dashboard/environment-dashboard-evidence.mapper.spec.ts test/modules/admin/environment-dashboard/environment-readonly-http.client.spec.ts --runInBand
|
||||||
|
```
|
||||||
|
Expected output: tests pass; no real network access is required because axios is mocked.
|
||||||
|
|
||||||
|
## Task 3A: API Platform Event Bus And MQTT Topic Contract
|
||||||
|
|
||||||
|
- [ ] Create `infrastructure/event/environment-mqtt-topic.catalog.ts` with documented topic builders:
|
||||||
|
- `signal(siteId, nodeId, serviceId)`
|
||||||
|
- `event(siteId, nodeId, serviceId)`
|
||||||
|
- `selfCheckResult(siteId)`
|
||||||
|
- `qqbotRuntime(selfId)`
|
||||||
|
- `qqbotNapcatLogin(selfId)`
|
||||||
|
- `pluginTaskRun(pluginKey, taskKey)`
|
||||||
|
|
||||||
|
- [ ] Create `infrastructure/event/environment-event-bus.service.ts`:
|
||||||
|
- supports `ENV_DASHBOARD_EVENT_BUS=local|mqtt`
|
||||||
|
- uses `ENV_DASHBOARD_MQTT_URL`, `ENV_DASHBOARD_MQTT_CLIENT_ID`, `ENV_DASHBOARD_MQTT_USERNAME`, `ENV_DASHBOARD_MQTT_PASSWORD`, and `ENV_DASHBOARD_MQTT_TOPIC_PREFIX`
|
||||||
|
- subscribes only to the environment dashboard topic prefix
|
||||||
|
- publishes local events through in-process subscribers for tests and dev
|
||||||
|
- marks broker disconnect as an environment event
|
||||||
|
- never logs broker credentials or full payloads
|
||||||
|
|
||||||
|
- [ ] Create `application/environment-event.materializer.ts`:
|
||||||
|
- accepts `EnvironmentEventEnvelope`
|
||||||
|
- rejects stale retained messages when `expiresAt` is earlier than current time
|
||||||
|
- appends safe recent events for dashboard response
|
||||||
|
- invalidates `environment-dashboard-cache.service.ts` when a non-stale signal event arrives
|
||||||
|
- never turns MQTT-only data into green status without fresh `observedAt` evidence
|
||||||
|
|
||||||
|
- [ ] Create `infrastructure/event/qqbot-environment-event.bridge.ts`:
|
||||||
|
- maps QQBot/NapCat runtime events into environment event envelopes
|
||||||
|
- depends on a narrow bus interface, not QQBot topic constants inside dashboard application code
|
||||||
|
- does not make environment dashboard depend on `QqbotBusService` implementation details
|
||||||
|
|
||||||
|
- [ ] Add RED/GREEN tests:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||||
|
pnpm exec jest --runTestsByPath test/modules/admin/environment-dashboard/environment-mqtt-topic.catalog.spec.ts test/modules/admin/environment-dashboard/environment-event-bus.service.spec.ts test/modules/admin/environment-dashboard/environment-event.materializer.spec.ts test/modules/admin/environment-dashboard/qqbot-environment-event.bridge.spec.ts --runInBand
|
||||||
|
```
|
||||||
|
Expected output after implementation: topic mapping is deterministic, expired retained signal becomes non-green, broker disconnect creates an event without marking every service down, and QQBot bridge tests pass without importing dashboard code from QQBot-specific topic constants.
|
||||||
|
|
||||||
|
## Task 3B: API SSE Stream For Admin Realtime Updates
|
||||||
|
|
||||||
|
- [ ] Create `application/environment-event-stream.service.ts`:
|
||||||
|
- exposes an RxJS Observable stream compatible with Nest `@Sse`
|
||||||
|
- subscribes to `EnvironmentEventBus` / `environment-event.materializer.ts`
|
||||||
|
- emits `environment-event`, `environment-signal`, `snapshot-required`, `heartbeat`, and `error`
|
||||||
|
- supports browser `Last-Event-ID` or `lastEventId` query fallback for replay
|
||||||
|
- maintains a bounded in-memory replay buffer controlled by `ENV_DASHBOARD_SSE_REPLAY_LIMIT`
|
||||||
|
- emits `snapshot-required` when the requested event id is older than the replay buffer
|
||||||
|
- uses `ENV_DASHBOARD_SSE_HEARTBEAT_MS` only for connection keepalive, not for dashboard refresh
|
||||||
|
- never opens MQTT or remote HTTP connections from the SSE method itself
|
||||||
|
|
||||||
|
- [ ] Add RED/GREEN tests:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||||
|
pnpm exec jest --runTestsByPath test/modules/admin/environment-dashboard/environment-event-stream.service.spec.ts --runInBand
|
||||||
|
```
|
||||||
|
Expected output after implementation: replay by last event id works, stale replay id emits `snapshot-required`, heartbeat does not trigger dashboard service calls, and stream payloads contain no broker credentials.
|
||||||
|
|
||||||
|
## Task 4: API Remote Adapters For Jenkins And Kubernetes
|
||||||
|
|
||||||
|
- [ ] Create `environment-dashboard-config.service.ts` that reads optional dashboard integration env vars. It returns explicit missing-key lists for each integration instead of throwing.
|
||||||
|
|
||||||
|
- [ ] Create `jenkins-readonly.adapter.ts`:
|
||||||
|
- uses Jenkins Remote Access API endpoint from env
|
||||||
|
- reads latest build/job metadata through GET
|
||||||
|
- maps configured-but-failing response to `down` or `degraded`
|
||||||
|
- maps missing URL/token/job to `unwired`
|
||||||
|
- never triggers builds
|
||||||
|
|
||||||
|
- [ ] Create `kubernetes-readonly.adapter.ts`:
|
||||||
|
- uses Kubernetes API server, namespace, label selector, deployment name, and bearer token from env
|
||||||
|
- reads Deployment and Pod metadata through GET
|
||||||
|
- maps generation mismatch or not-ready pods to `degraded`
|
||||||
|
- maps API unreachable to `isolated`
|
||||||
|
- maps missing config to `unwired`
|
||||||
|
- never sends PATCH/POST/DELETE
|
||||||
|
|
||||||
|
- [ ] Add adapter tests:
|
||||||
|
- missing config produces `unwired` evidence with exact missing keys
|
||||||
|
- success response produces `live`
|
||||||
|
- HTTP failure produces non-green status
|
||||||
|
|
||||||
|
- [ ] Run:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||||
|
pnpm exec jest --runTestsByPath test/modules/admin/environment-dashboard/jenkins-readonly.adapter.spec.ts test/modules/admin/environment-dashboard/kubernetes-readonly.adapter.spec.ts --runInBand
|
||||||
|
```
|
||||||
|
Expected output: tests pass with mocked HTTP client.
|
||||||
|
|
||||||
|
## Task 5: API Remote Adapters For Tencent Cloud, Caddy, WireGuard, And Mihomo
|
||||||
|
|
||||||
|
- [ ] Create `tencent-cloud-readonly.adapter.ts`:
|
||||||
|
- uses `tencentcloud-sdk-nodejs`
|
||||||
|
- reads CVM instance status and Cloud Monitor summaries only when credentials and instance IDs are configured
|
||||||
|
- maps missing credentials to `unwired`
|
||||||
|
- maps SDK errors to `isolated` or `unknown` with error evidence
|
||||||
|
- stores no secret in evidence or logs
|
||||||
|
|
||||||
|
- [ ] Create `caddy-readonly.adapter.ts`:
|
||||||
|
- reads configured public URL with HEAD/GET
|
||||||
|
- optionally reads Caddy Admin API config only when admin URL is configured
|
||||||
|
- maps missing admin API to an `unwired` signal and public URL success to `live`
|
||||||
|
- never reloads or writes Caddy config
|
||||||
|
|
||||||
|
- [ ] Create `wireguard-readonly.adapter.ts`:
|
||||||
|
- reads only configured health endpoints or public route checks for Tencent Cloud and r4se WireGuard
|
||||||
|
- if no safe endpoint is configured, returns `unwired` with evidence explaining the absent read source
|
||||||
|
- does not SSH into hosts in the dashboard request path
|
||||||
|
|
||||||
|
- [ ] Create `mihomo-readonly.adapter.ts`:
|
||||||
|
- reads Mihomo/OpenClash REST API `GET /version`, `GET /configs`, and `GET /proxies` when URL and secret are configured
|
||||||
|
- maps missing URL/secret to `unwired`
|
||||||
|
- never changes selector, proxy, rule, or config
|
||||||
|
|
||||||
|
- [ ] Add tests for each adapter using mocked clients:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||||
|
pnpm exec jest --runTestsByPath test/modules/admin/environment-dashboard/tencent-cloud-readonly.adapter.spec.ts test/modules/admin/environment-dashboard/caddy-readonly.adapter.spec.ts test/modules/admin/environment-dashboard/wireguard-readonly.adapter.spec.ts test/modules/admin/environment-dashboard/mihomo-readonly.adapter.spec.ts --runInBand
|
||||||
|
```
|
||||||
|
Expected output: tests pass; Tencent SDK is mocked.
|
||||||
|
|
||||||
|
## Task 6: API Internal Signal Collectors
|
||||||
|
|
||||||
|
- [ ] Create `local-dev-signal.collector.ts` for local-dev site:
|
||||||
|
- API process/runtime health from `RuntimeHealthService.getRuntimeHealth()`
|
||||||
|
- local Admin proxy/config as configured source
|
||||||
|
- local dependency gaps shown as `unknown` or `unwired`
|
||||||
|
|
||||||
|
- [ ] Create `nas-prod-signal.collector.ts` for NAS production site:
|
||||||
|
- Runtime health from `RuntimeHealthService`
|
||||||
|
- QQBot summary from `QqbotDashboardService.summary()`
|
||||||
|
- NapCat runtime/login visibility through exported NapCat runtime port or explicitly exported service
|
||||||
|
- plugin platform/task visibility through `QqbotPluginPlatformService` and exported task service
|
||||||
|
- MinIO connection through existing `MinioClientService.checkConnection`
|
||||||
|
- WordPress integration through `WordpressService.tryLoginWithConfiguredAdmin()`
|
||||||
|
- Jenkins/K8s through adapters from Task 4
|
||||||
|
- Loki/MySQL/Redis as configured or derived signals when no direct safe adapter exists
|
||||||
|
|
||||||
|
- [ ] Update module exports only where the collector needs existing services:
|
||||||
|
- `src/modules/qqbot/core/qqbot-core.module.ts`: export `QqbotDashboardService`
|
||||||
|
- `src/modules/qqbot/plugin-platform/qqbot-plugin-platform.module.ts`: export `QqbotPluginTaskService` if task status counts are required
|
||||||
|
- Prefer existing exported NapCat runtime port before exporting concrete NapCat services
|
||||||
|
|
||||||
|
- [ ] Add `nas-prod-signal.collector.spec.ts` with mocked dependencies proving:
|
||||||
|
- QQBot offline does not mark API down
|
||||||
|
- one disabled plugin task appears as degraded service evidence
|
||||||
|
- MinIO failure is contained to MinIO service
|
||||||
|
- missing Jenkins/K8s config remains `unwired`
|
||||||
|
|
||||||
|
- [ ] Run:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||||
|
pnpm exec jest --runTestsByPath test/modules/admin/environment-dashboard/nas-prod-signal.collector.spec.ts --runInBand
|
||||||
|
```
|
||||||
|
Expected output: tests pass.
|
||||||
|
|
||||||
|
## Task 7: API Dashboard Aggregator, Cache, And Self-Check
|
||||||
|
|
||||||
|
- [ ] Create `environment-dashboard-cache.service.ts`:
|
||||||
|
- caches successful aggregate response for `ENV_DASHBOARD_CACHE_TTL_MS`
|
||||||
|
- marks cached signals with `sourceKind: 'cached'`
|
||||||
|
- does not cache thrown exceptions as green results
|
||||||
|
|
||||||
|
- [ ] Create `environment-dashboard.service.ts`:
|
||||||
|
- assembles four sites: `local-dev`, `nas-prod`, `tencent-cloud`, `r4se`
|
||||||
|
- aggregates summary counters
|
||||||
|
- merges safe recent events from `environment-event.materializer.ts`
|
||||||
|
- builds topology edges:
|
||||||
|
- local-dev -> API/Admin local services
|
||||||
|
- nas-prod -> Jenkins/K8s/API/Admin/MySQL/Redis/Loki/MinIO/WordPress/QQBot/NapCat/Plugin Platform/Plugin Tasks
|
||||||
|
- tencent-cloud -> WireGuard/Caddy
|
||||||
|
- r4se -> WireGuard/OpenClash
|
||||||
|
- attaches action catalog
|
||||||
|
- returns deterministic IDs for UI selection
|
||||||
|
|
||||||
|
- [ ] Create `environment-dashboard-self-check.service.ts`:
|
||||||
|
- runs the same collectors with `forceRefresh: true`
|
||||||
|
- returns `EnvironmentDashboardResponse`
|
||||||
|
- records event entries for failed adapters through `EnvironmentEventBus`
|
||||||
|
- performs no write operation
|
||||||
|
|
||||||
|
- [ ] Add service tests:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||||
|
pnpm exec jest --runTestsByPath test/modules/admin/environment-dashboard/environment-dashboard.service.spec.ts test/modules/admin/environment-dashboard/environment-dashboard-self-check.service.spec.ts --runInBand
|
||||||
|
```
|
||||||
|
Expected output: tests pass and generated topology includes all four sites.
|
||||||
|
|
||||||
|
## Task 8: API Controller, Module Wiring, Env Docs, And Local HTTP Smoke
|
||||||
|
|
||||||
|
- [ ] Create `presentation/dto/environment-dashboard.dto.ts` with Swagger DTO classes. Keep DTO shape aligned to the domain response.
|
||||||
|
|
||||||
|
- [ ] Create `presentation/environment-dashboard.controller.ts`:
|
||||||
|
- `GET /system/environment/dashboard`
|
||||||
|
- `POST /system/environment/self-check`
|
||||||
|
- `GET /system/environment/events/stream`
|
||||||
|
- guards: `JwtAuthGuard`
|
||||||
|
- response wrapper: existing `vbenSuccess`
|
||||||
|
- `events/stream` uses Nest `@Sse` and returns `EnvironmentStreamEvent`
|
||||||
|
- JSDoc on controller methods states Admin route origin and no side effects beyond read-only probes/cache refresh/SSE subscription
|
||||||
|
|
||||||
|
- [ ] Update `admin-platform-config.module.ts` imports/providers/controllers. Include only required modules.
|
||||||
|
|
||||||
|
- [ ] Update `.env.example`, `README.md`, and `API.md` with optional env variables and read-only semantics:
|
||||||
|
```env
|
||||||
|
ENV_DASHBOARD_CACHE_TTL_MS=15000
|
||||||
|
ENV_DASHBOARD_SIGNAL_TIMEOUT_MS=5000
|
||||||
|
ENV_DASHBOARD_EVENT_BUS=local
|
||||||
|
ENV_DASHBOARD_MQTT_URL=
|
||||||
|
ENV_DASHBOARD_MQTT_CLIENT_ID=kt-template-online-api-environment
|
||||||
|
ENV_DASHBOARD_MQTT_USERNAME=
|
||||||
|
ENV_DASHBOARD_MQTT_PASSWORD=
|
||||||
|
ENV_DASHBOARD_MQTT_TOPIC_PREFIX=kt/env
|
||||||
|
ENV_DASHBOARD_SSE_REPLAY_LIMIT=200
|
||||||
|
ENV_DASHBOARD_SSE_HEARTBEAT_MS=25000
|
||||||
|
ENV_DASHBOARD_JENKINS_URL=
|
||||||
|
ENV_DASHBOARD_JENKINS_JOB=KT-Template/KT-Template-API/main
|
||||||
|
ENV_DASHBOARD_JENKINS_USERNAME=
|
||||||
|
ENV_DASHBOARD_JENKINS_TOKEN=
|
||||||
|
ENV_DASHBOARD_K8S_API_SERVER=
|
||||||
|
ENV_DASHBOARD_K8S_NAMESPACE=kt-prod
|
||||||
|
ENV_DASHBOARD_K8S_DEPLOYMENT=kt-template-online-api
|
||||||
|
ENV_DASHBOARD_K8S_LABEL_SELECTOR=app=kt-template-online-api
|
||||||
|
ENV_DASHBOARD_K8S_BEARER_TOKEN=
|
||||||
|
ENV_DASHBOARD_TENCENT_CLOUD_ENABLED=false
|
||||||
|
ENV_DASHBOARD_TENCENT_SECRET_ID=
|
||||||
|
ENV_DASHBOARD_TENCENT_SECRET_KEY=
|
||||||
|
ENV_DASHBOARD_TENCENT_REGION=
|
||||||
|
ENV_DASHBOARD_TENCENT_INSTANCE_ID=
|
||||||
|
ENV_DASHBOARD_CADDY_ADMIN_URL=
|
||||||
|
ENV_DASHBOARD_CADDY_PUBLIC_URL=
|
||||||
|
ENV_DASHBOARD_R4SE_MIHOMO_URL=
|
||||||
|
ENV_DASHBOARD_R4SE_MIHOMO_SECRET=
|
||||||
|
ENV_DASHBOARD_TENCENT_WIREGUARD_HEALTH_URL=
|
||||||
|
ENV_DASHBOARD_R4SE_WIREGUARD_HEALTH_URL=
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Run API targeted tests:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||||
|
pnpm exec jest --runTestsByPath test/modules/admin/environment-dashboard/environment-dashboard.controller.spec.ts --runInBand
|
||||||
|
```
|
||||||
|
Expected output: controller spec passes.
|
||||||
|
|
||||||
|
- [ ] Add controller spec coverage for SSE:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||||
|
pnpm exec jest --runTestsByPath test/modules/admin/environment-dashboard/environment-dashboard.events.controller.spec.ts --runInBand
|
||||||
|
```
|
||||||
|
Expected output: authenticated request can subscribe to SSE, `Last-Event-ID` is passed to stream service, and no dashboard polling timer exists in API code.
|
||||||
|
|
||||||
|
- [ ] Run API typecheck:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||||
|
pnpm run typecheck
|
||||||
|
```
|
||||||
|
Expected output: no TypeScript errors.
|
||||||
|
|
||||||
|
- [ ] Start or reuse local API service, then make real local requests:
|
||||||
|
```powershell
|
||||||
|
curl.exe -H "Authorization: Bearer <local-admin-token>" http://127.0.0.1:<api-port>/system/environment/dashboard
|
||||||
|
curl.exe -X POST -H "Authorization: Bearer <local-admin-token>" http://127.0.0.1:<api-port>/system/environment/self-check
|
||||||
|
curl.exe -N -H "Authorization: Bearer <local-admin-token>" http://127.0.0.1:<api-port>/system/environment/events/stream
|
||||||
|
```
|
||||||
|
Expected output: dashboard and self-check return `code: 0` or existing success wrapper equivalent, four site records, and at least one `unwired` evidence item when remote env vars are absent; SSE stream returns `text/event-stream` and emits a heartbeat or synthetic environment event without closing immediately.
|
||||||
|
|
||||||
|
## Task 9: Admin API Wrapper And RED Tests
|
||||||
|
|
||||||
|
- [ ] Create `apps/web-antdv-next/src/api/system/environment.ts` with exported interfaces matching the API response and functions:
|
||||||
|
- `getEnvironmentDashboard()`
|
||||||
|
- `runEnvironmentSelfCheck()`
|
||||||
|
- `getEnvironmentDashboardEventsUrl(lastEventId?)`
|
||||||
|
|
||||||
|
- [ ] Add `apps/web-antdv-next/src/api/system/environment.spec.ts`:
|
||||||
|
```ts
|
||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
import {
|
||||||
|
getEnvironmentDashboard,
|
||||||
|
getEnvironmentDashboardEventsUrl,
|
||||||
|
runEnvironmentSelfCheck,
|
||||||
|
} from './environment';
|
||||||
|
|
||||||
|
vi.mock('#/api/request', () => ({
|
||||||
|
requestClient: {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('environment dashboard api', () => {
|
||||||
|
it('loads the aggregate dashboard', async () => {
|
||||||
|
await getEnvironmentDashboard();
|
||||||
|
expect(requestClient.get).toHaveBeenCalledWith('/system/environment/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs readonly self check', async () => {
|
||||||
|
await runEnvironmentSelfCheck();
|
||||||
|
expect(requestClient.post).toHaveBeenCalledWith('/system/environment/self-check');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds the SSE stream url without exposing MQTT config', () => {
|
||||||
|
expect(getEnvironmentDashboardEventsUrl()).toContain('/system/environment/events/stream');
|
||||||
|
expect(getEnvironmentDashboardEventsUrl('evt-1')).toContain('lastEventId=evt-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Run RED, implement, rerun:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT\Vue\kt-template-admin
|
||||||
|
pnpm exec vitest run apps/web-antdv-next/src/api/system/environment.spec.ts
|
||||||
|
```
|
||||||
|
Expected output after implementation: tests pass.
|
||||||
|
|
||||||
|
## Task 10: Admin Component Shell And Visual States
|
||||||
|
|
||||||
|
- [ ] Replace the old sample content in `apps/web-antdv-next/src/views/dashboard/analytics/index.vue` with a single route root element. Do not keep old Vben analysis sample cards/charts.
|
||||||
|
|
||||||
|
- [ ] Create `types.ts` for view-only types. Keep it aligned with API wrapper types by importing from the API wrapper where practical.
|
||||||
|
|
||||||
|
- [ ] Create `EnvironmentStatusBar.vue`:
|
||||||
|
- global status, generated/refreshed time, signal counters
|
||||||
|
- refresh and self-check buttons using antdv-next buttons/icons
|
||||||
|
- loading and error state
|
||||||
|
|
||||||
|
- [ ] Create `EnvironmentSiteRail.vue`:
|
||||||
|
- site cards/list items for local-dev, nas-prod, tencent-cloud, r4se
|
||||||
|
- status badge per site
|
||||||
|
- evidence count and unwired count
|
||||||
|
|
||||||
|
- [ ] Create `EnvironmentTopology.vue`:
|
||||||
|
- responsive service topology using semantic HTML/CSS grid, not canvas-only rendering
|
||||||
|
- service nodes show status, source kind, and selected state
|
||||||
|
- links/edges represented accessibly and remain readable on 1366px desktop
|
||||||
|
|
||||||
|
- [ ] Create `EnvironmentEvidencePanel.vue`:
|
||||||
|
- selected site/service/signal details
|
||||||
|
- evidence list grouped by source
|
||||||
|
- action list with disabled high-risk actions visibly disabled and reason text
|
||||||
|
- no write endpoint calls
|
||||||
|
|
||||||
|
- [ ] Create `EnvironmentEventStream.vue`:
|
||||||
|
- latest events sorted newest first
|
||||||
|
- status/source kind tags
|
||||||
|
- compact display with stable row height
|
||||||
|
|
||||||
|
- [ ] Styling constraints:
|
||||||
|
- use `antdv-next` components already present in the app
|
||||||
|
- avoid marketing hero, gradient blobs, decorative cards inside cards
|
||||||
|
- ensure mobile layout stacks as top status -> site rail -> topology -> evidence -> events
|
||||||
|
- text must not overlap at 390px, 768px, 1366px, and 1920px widths
|
||||||
|
|
||||||
|
## Task 11: Admin Page Integration, Locales, And Tests
|
||||||
|
|
||||||
|
- [ ] Wire `index.vue`:
|
||||||
|
- load dashboard once on mount
|
||||||
|
- start `useEnvironmentDashboardStream.ts` EventSource after the first snapshot succeeds
|
||||||
|
- refresh with `getEnvironmentDashboard`
|
||||||
|
- self-check with `runEnvironmentSelfCheck`
|
||||||
|
- process `environment-event` and `environment-signal` without fetching dashboard again
|
||||||
|
- process `snapshot-required` by running exactly one dashboard snapshot request
|
||||||
|
- ignore `heartbeat` for state updates
|
||||||
|
- preserve selected site/service when IDs still exist after refresh
|
||||||
|
- select first degraded/down/blocked service by default, else first site
|
||||||
|
- display explicit empty/error state when API fails
|
||||||
|
- close EventSource on route leave/unmount
|
||||||
|
|
||||||
|
- [ ] Create `composables/useEnvironmentDashboardStream.ts`:
|
||||||
|
- wraps browser `EventSource`
|
||||||
|
- uses `getEnvironmentDashboardEventsUrl(lastEventId?)`
|
||||||
|
- stores last accepted event id in component state only
|
||||||
|
- exposes connection state for the status bar
|
||||||
|
- does not use `setInterval`, `setTimeout` refresh loops, or background polling
|
||||||
|
- lets native EventSource reconnect; after server sends `snapshot-required`, caller performs one snapshot fetch
|
||||||
|
|
||||||
|
- [ ] Update locale title from analytics sample to environment dashboard:
|
||||||
|
- `apps/web-antdv-next/src/locales/langs/zh-CN/page.json`: `环境总览`
|
||||||
|
- `apps/web-antdv-next/src/locales/langs/en-US/page.json`: `Environment`
|
||||||
|
|
||||||
|
- [ ] Add page spec `environment-dashboard.spec.tsx`:
|
||||||
|
- mocked API returns all four sites
|
||||||
|
- disabled write actions render disabled
|
||||||
|
- unwired Jenkins/K8s evidence renders as evidence, not healthy state
|
||||||
|
- MQTT-origin recent event renders only after API returns it; page does not instantiate an MQTT client
|
||||||
|
- SSE `environment-signal` updates one node without calling `getEnvironmentDashboard`
|
||||||
|
- SSE `snapshot-required` calls `getEnvironmentDashboard` once
|
||||||
|
- no dashboard polling or timer-based refresh is registered
|
||||||
|
- self-check button calls the POST wrapper
|
||||||
|
|
||||||
|
- [ ] Run:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT\Vue\kt-template-admin
|
||||||
|
pnpm exec vitest run apps/web-antdv-next/src/views/dashboard/analytics/environment-dashboard.spec.tsx
|
||||||
|
```
|
||||||
|
Expected output: tests pass.
|
||||||
|
|
||||||
|
- [ ] Run Admin typecheck:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT\Vue\kt-template-admin
|
||||||
|
pnpm -F @vben/web-antdv-next run typecheck
|
||||||
|
```
|
||||||
|
Expected output: no TypeScript errors.
|
||||||
|
|
||||||
|
## Task 12: Local Page Smoke And Interface Smoke
|
||||||
|
|
||||||
|
- [ ] Start or reuse local API and Admin dev servers. Record ports and process IDs under `.kt-workspace/test-artifacts/environment-dashboard/`.
|
||||||
|
|
||||||
|
- [ ] In the in-app browser or Playwright, open `/dashboard/analytics` and verify:
|
||||||
|
- top global status bar is visible
|
||||||
|
- left site rail contains local-dev, nas-prod, tencent-cloud, r4se
|
||||||
|
- topology contains Jenkins, K8s, QQBot/NapCat, Plugin Tasks, Tencent Caddy/WireGuard, r4se OpenClash/WireGuard
|
||||||
|
- right panel shows evidence for selected item
|
||||||
|
- bottom event stream appears
|
||||||
|
- event stream can display an API-provided MQTT-origin event without exposing broker configuration in frontend state
|
||||||
|
- Network tab shows one dashboard snapshot and one `events/stream` long connection after page load, with no repeated dashboard polling
|
||||||
|
- closing/reopening the route closes the old EventSource before creating a new one
|
||||||
|
- Jenkins/K8s missing config appears as `unwired` or `unknown`, never green
|
||||||
|
- disabled write actions cannot be clicked
|
||||||
|
|
||||||
|
- [ ] Capture screenshots:
|
||||||
|
- desktop 1366x768
|
||||||
|
- wide desktop 1920x1080
|
||||||
|
- mobile 390x844
|
||||||
|
Store under `.kt-workspace/test-artifacts/environment-dashboard/`.
|
||||||
|
|
||||||
|
- [ ] Make one real local HTTP call from Admin session to both endpoints through the browser network panel or request logs. Evidence must include endpoint, HTTP status, and presence of four site IDs.
|
||||||
|
- [ ] Make one real local SSE connection from Admin session. Evidence must include endpoint, HTTP status, `text/event-stream`, and no repeated dashboard GET requests after the initial snapshot.
|
||||||
|
|
||||||
|
- [ ] Stop Node/Vite processes started by this task if they were not already running before the task.
|
||||||
|
|
||||||
|
## Task 13: KT Loops, Review, Commit, And Push Gate
|
||||||
|
|
||||||
|
- [ ] Run documentation sync check for changed files:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT
|
||||||
|
pnpm --dir mcp/ktWorkflow run change-doc-sync -- --project Node/kt-template-online-api --changed "src/modules/admin/platform-config/environment-dashboard,.env.example,README.md,API.md"
|
||||||
|
pnpm --dir mcp/ktWorkflow run change-doc-sync -- --project Vue/kt-template-admin --changed "apps/web-antdv-next/src/views/dashboard/analytics,apps/web-antdv-next/src/api/system/environment.ts"
|
||||||
|
```
|
||||||
|
Expected output: either required docs are updated or the script records that no additional docs are required.
|
||||||
|
|
||||||
|
- [ ] Run cleanup dry-run and execute only stale `.kt-workspace` cleanup if reported:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT
|
||||||
|
pnpm --dir mcp/ktWorkflow run cleanup-history -- --dry-run
|
||||||
|
```
|
||||||
|
Expected output: if `deleted=[]`, no execute run is needed; if stale files are listed, run with `--execute` and rerun dry-run.
|
||||||
|
|
||||||
|
- [ ] Run global review for API and Admin:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT
|
||||||
|
pnpm --dir mcp/ktWorkflow run global-review -- --project Node/kt-template-online-api
|
||||||
|
pnpm --dir mcp/ktWorkflow run global-review -- --project Vue/kt-template-admin
|
||||||
|
```
|
||||||
|
Expected output: no Important findings remain. Any finding that is a false positive must be documented with evidence.
|
||||||
|
|
||||||
|
- [ ] Update `D:\MyFiles\KT\TASKS.md` with concise scope, changed files, validation evidence, and remaining online env wiring gaps.
|
||||||
|
|
||||||
|
- [ ] Commit API repo:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT\Node\kt-template-online-api
|
||||||
|
git status --short
|
||||||
|
git add package.json pnpm-lock.yaml .env.example README.md API.md src/modules/admin/platform-config src/modules/qqbot/core/qqbot-core.module.ts src/modules/qqbot/plugin-platform/qqbot-plugin-platform.module.ts test/modules/admin/environment-dashboard docs/superpowers/plans/2026-06-18-admin-environment-dashboard-implementation-plan.md
|
||||||
|
git commit -m "feat: 新增环境总览总控面板接口"
|
||||||
|
```
|
||||||
|
Expected output: one API commit. Do not stage secrets.
|
||||||
|
|
||||||
|
- [ ] Commit Admin repo:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT\Vue\kt-template-admin
|
||||||
|
git status --short
|
||||||
|
git add apps/web-antdv-next/src/api/system/environment.ts apps/web-antdv-next/src/api/system/environment.spec.ts apps/web-antdv-next/src/views/dashboard/analytics apps/web-antdv-next/src/locales/langs/zh-CN/page.json apps/web-antdv-next/src/locales/langs/en-US/page.json
|
||||||
|
git commit -m "feat: 重构分析页为环境总览总控面板"
|
||||||
|
```
|
||||||
|
Expected output: one Admin commit.
|
||||||
|
|
||||||
|
- [ ] Commit KT root if `TASKS.md` changed:
|
||||||
|
```powershell
|
||||||
|
cd D:\MyFiles\KT
|
||||||
|
git status --short
|
||||||
|
git add TASKS.md
|
||||||
|
git commit -m "docs: 同步环境总览面板任务状态"
|
||||||
|
```
|
||||||
|
Expected output: one root coordination commit.
|
||||||
|
|
||||||
|
- [ ] Push only after the user explicitly asks for push or deployment:
|
||||||
|
```powershell
|
||||||
|
git push origin codex/admin-environment-dashboard
|
||||||
|
```
|
||||||
|
Expected output: remote branch pushed. If push triggers deployment, Jenkins/K8s observation and online self-test are required before claiming online completion.
|
||||||
|
|
||||||
|
## Online Wiring Checklist After Implementation
|
||||||
|
|
||||||
|
When the user asks to deploy or test online, wire environment variables in the online secret/config system without committing values:
|
||||||
|
|
||||||
|
- Jenkins read-only URL/job/user/token.
|
||||||
|
- K8s API server/namespace/deployment/selector/bearer token with read-only RBAC.
|
||||||
|
- Tencent Cloud official SDK credentials scoped to CVM/Cloud Monitor read-only APIs.
|
||||||
|
- Tencent Cloud Caddy public URL and optional Admin API URL if safely reachable.
|
||||||
|
- r4se Mihomo/OpenClash read-only API URL and secret.
|
||||||
|
- WireGuard health endpoints for Tencent Cloud and r4se, or explicit unwired status if no safe read endpoint exists.
|
||||||
|
|
||||||
|
Online completion evidence must include:
|
||||||
|
|
||||||
|
- `GET /system/environment/dashboard` returns four sites.
|
||||||
|
- `POST /system/environment/self-check` returns fresh evidence.
|
||||||
|
- `GET /system/environment/events/stream` establishes an SSE stream; a backend environment event reaches Admin without polling.
|
||||||
|
- MQTT event bus is either `local` with explicit configured evidence, or `mqtt` with broker connection status shown as evidence.
|
||||||
|
- A synthetic non-secret MQTT signal event invalidates dashboard cache and appears in recent events; an expired retained signal does not create a green status.
|
||||||
|
- Jenkins/K8s nodes show live evidence when configured, or explicit missing-key evidence when not configured.
|
||||||
|
- Tencent Cloud and r4se nodes do not show green health without a live/configured source.
|
||||||
|
- Admin page displays the same state without console errors.
|
||||||
@ -0,0 +1,306 @@
|
|||||||
|
# QQBot NapCat Device Name And Timezone Hardening Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Restore a visible QQ security-page device name while keeping NapCat device identity persistent, fix mixed UTC/Asia-Shanghai timestamps, and make runtime/profile evidence persist online.
|
||||||
|
|
||||||
|
**Architecture:** Use upstream NapCat evidence as the source of truth: QQNT Linux reads `os.hostname()` for `hostName`/`devName`, new-device OIDB sends `str_dev_name: os.hostname()`, and Linux GUID is based on `/etc/machine-id + machine-info MAC`. The online regression evidence shows the new container exposes `ubuntu-pc-*`, but Docker MAC was changed to a physical OUI while QQNT `machine-info` still stored the old `02:42:*` MAC, creating inconsistent device identity. Generate a short stable QQNT-safe hostname that the upstream Docker entrypoint will not rewrite, return MAC strategy to upstream-compatible stable Docker bridge `02:42:*`, write matching `machine-info`, persist profile rows after container creation, and set MySQL connection timezone explicitly.
|
||||||
|
|
||||||
|
**Tech Stack:** NestJS, TypeORM, MySQL, NapCat-Docker, Jest, PowerShell/NAS SSH smoke.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
- Modify `src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts`
|
||||||
|
- Generate short stable hostname `pc-<8 hex>` to avoid NapCat-Docker entrypoint reset and avoid long desktop names that regress to `未知设备`.
|
||||||
|
- Generate stable upstream-compatible Docker bridge MAC `02:42:*`.
|
||||||
|
- Migrate existing `desktop-hostname-v1` / `physical-oui-v1` identities back to QQNT-safe defaults with evidence.
|
||||||
|
- Modify `src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options.ts`
|
||||||
|
- Carry `machineInfoPath` and hyphen MAC for QQNT Linux `machine-info`.
|
||||||
|
- Modify `src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts`
|
||||||
|
- Write `device.env`, `/etc/machine-id`, and QQNT `machine-info` consistently.
|
||||||
|
- Create and mount a persistent `XDG_RUNTIME_DIR`.
|
||||||
|
- Update `napcat_account_binding.device_identity_id` after create/rebuild.
|
||||||
|
- Persist runtime/protocol profile after successful container creation.
|
||||||
|
- Modify `src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts`
|
||||||
|
- Persist planned runtime and protocol profile rows when repositories are injected.
|
||||||
|
- Modify `src/app.module.ts`
|
||||||
|
- Set TypeORM MySQL timezone from `DB_TIMEZONE`, default `+08:00`.
|
||||||
|
- Modify `src/runtime/config/runtime-config.types.ts` and `src/runtime/config/runtime-config.service.ts`
|
||||||
|
- Expose DB timezone in runtime health evidence.
|
||||||
|
- Modify `ci/napcat-desktop-cn/Dockerfile`
|
||||||
|
- Ensure the derived image keeps `zh_CN.UTF-8` available and does not get shadowed by upstream `C.UTF-8`.
|
||||||
|
- Modify `.env.example`, `README.md`, `API.md`, `sql/qqbot-init.sql`, `sql/refactor-v3/00-full-schema.sql`, `sql/refactor-v3/99-verify.sql`
|
||||||
|
- Document/align defaults and schema verification.
|
||||||
|
- Modify tests:
|
||||||
|
- `test/modules/qqbot/napcat/device-identity.spec.ts`
|
||||||
|
- `test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts`
|
||||||
|
- `test/runtime/runtime-config.service.spec.ts`
|
||||||
|
- Add `test/app/typeorm-timezone-config.spec.ts`
|
||||||
|
|
||||||
|
## Task 1: Device Identity Regression RED/GREEN
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `test/modules/qqbot/napcat/device-identity.spec.ts`
|
||||||
|
- Modify: `src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts`
|
||||||
|
- Modify: `src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options.ts`
|
||||||
|
|
||||||
|
- [x] **Step 1: Write failing tests**
|
||||||
|
|
||||||
|
Add expectations that new identities use a QQNT-safe visible hostname and upstream-compatible MAC:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
expect(identity.hostname).toMatch(/^pc-[a-f0-9]{8}$/);
|
||||||
|
expect(identity.hostname).not.toMatch(/^[a-f0-9]{12,}$/);
|
||||||
|
expect(identity.macAddress).toMatch(/^02:42:[0-9a-f]{2}:[0-9a-f]{2}:[0-9a-f]{2}:[0-9a-f]{2}$/);
|
||||||
|
expect(identity.hostnameStrategy).toBe('qqnt-visible-hostname-v1');
|
||||||
|
expect(identity.macStrategy).toBe('docker-bridge-mac-v1');
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update the migration test so a legacy `ubuntu-pc-*` / physical OUI identity migrates to the new strategies and records `trigger: 'qqnt-device-name-regression-repair'`.
|
||||||
|
|
||||||
|
- [x] **Step 2: Run RED**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm exec jest --runTestsByPath test/modules/qqbot/napcat/device-identity.spec.ts --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because current code emits `ubuntu-pc-*` and physical OUI MAC.
|
||||||
|
|
||||||
|
- [x] **Step 3: Implement identity generation**
|
||||||
|
|
||||||
|
Change the service to:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
private buildQqntVisibleHostname(seed: string) {
|
||||||
|
const hash = createHash('sha256').update(seed).digest('hex');
|
||||||
|
return `pc-${hash.slice(0, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDockerBridgeMacAddress(accountId: string, containerName: string) {
|
||||||
|
const hash = createHash('sha256')
|
||||||
|
.update(`${accountId}:${containerName}:docker-bridge-mac-v1`)
|
||||||
|
.digest('hex');
|
||||||
|
return `02:42:${hash.slice(0, 2)}:${hash.slice(2, 4)}:${hash.slice(4, 6)}:${hash.slice(6, 8)}`.toLowerCase();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update migration predicates to migrate too-long hostnames, pure 12+ hex hostnames, `desktop-hostname-v1`, `physical-oui-v1`, rejected virtual prefixes outside `02:42`, and values containing `qq|bot|napcat|docker|container|lxc`.
|
||||||
|
|
||||||
|
- [x] **Step 4: Run GREEN**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm exec jest --runTestsByPath test/modules/qqbot/napcat/device-identity.spec.ts --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
## Task 2: Docker Script Machine-Info And Binding RED/GREEN
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `test/modules/qqbot/napcat/device-identity.spec.ts`
|
||||||
|
- Modify: `src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options.ts`
|
||||||
|
- Modify: `src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts`
|
||||||
|
|
||||||
|
- [x] **Step 1: Write failing tests**
|
||||||
|
|
||||||
|
Assert the generated script writes QQNT machine-info with a backup, mounts persistent runtime dir, and updates binding `deviceIdentityId`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
expect(createScript).toContain('MACHINE_INFO_PATH=');
|
||||||
|
expect(createScript).toContain('MAC_HYPHEN=');
|
||||||
|
expect(createScript).toContain("printf '\\\\000\\\\000\\\\000\\\\021'");
|
||||||
|
expect(createScript).toContain("tr 'A-Za-z' 'N-ZA-Mn-za-m'");
|
||||||
|
expect(createScript).toContain('-v "$DATA_DIR/runtime:/tmp/runtime-napcat"');
|
||||||
|
expect(bindingRepository.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ accountId: 'account-10001', containerId: 'container-created' }),
|
||||||
|
expect.objectContaining({ deviceIdentityId: expect.any(String) }),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run RED**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm exec jest --runTestsByPath test/modules/qqbot/napcat/device-identity.spec.ts --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because `machine-info` and binding updates are not implemented.
|
||||||
|
|
||||||
|
- [x] **Step 3: Implement script and binding update**
|
||||||
|
|
||||||
|
Add `machineInfoPath` and `macAddressHyphen` to `NapcatDockerDeviceOptions`. In `buildRemoteCreateScript`, write:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
MACHINE_INFO_PATH="$DATA_DIR/QQ/nt_qq/global/nt_data/msf/machine-info"
|
||||||
|
NAPCAT_MAC_HYPHEN="$(printf '%s' "$NAPCAT_MAC_ADDRESS" | tr ':' '-')"
|
||||||
|
mkdir -p "$(dirname "$MACHINE_INFO_PATH")"
|
||||||
|
if [ -s "$MACHINE_INFO_PATH" ]; then
|
||||||
|
CURRENT_MACHINE_INFO="$(mktemp)"
|
||||||
|
cp "$MACHINE_INFO_PATH" "$CURRENT_MACHINE_INFO"
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
printf '\000\000\000\021'
|
||||||
|
printf '%s' "$MAC_HYPHEN" | tr 'A-Za-z' 'N-ZA-Mn-za-m'
|
||||||
|
} > "$MACHINE_INFO_PATH"
|
||||||
|
```
|
||||||
|
|
||||||
|
After successful create/rebuild, update `napcat_account_binding.device_identity_id` for the account/container pair.
|
||||||
|
|
||||||
|
- [x] **Step 4: Run GREEN**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm exec jest --runTestsByPath test/modules/qqbot/napcat/device-identity.spec.ts --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
## Task 3: Runtime/Profile Persistence RED/GREEN
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts`
|
||||||
|
- Modify: `src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts`
|
||||||
|
- Modify: `src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts`
|
||||||
|
|
||||||
|
- [x] **Step 1: Write failing tests**
|
||||||
|
|
||||||
|
Add a test that calls `recordPlannedProfiles()` and expects both repositories to save rows:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
expect(runtimeProfileRepository.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
accountId: 'account-1',
|
||||||
|
deviceIdentityId: 'identity-1',
|
||||||
|
hostnameStrategy: 'qqnt-visible-hostname-v1',
|
||||||
|
macStrategy: 'docker-bridge-mac-v1',
|
||||||
|
profileStatus: 'pending',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(protocolProfileRepository.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
accountId: 'account-1',
|
||||||
|
o3HookMode: 1,
|
||||||
|
packetBackend: 'auto',
|
||||||
|
profileStatus: 'pending',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run RED**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm exec jest --runTestsByPath test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the service currently does not persist generated profiles.
|
||||||
|
|
||||||
|
- [x] **Step 3: Implement persistence**
|
||||||
|
|
||||||
|
Add optional injected repositories to `NapcatRuntimeProfileService`, implement `recordPlannedProfiles(input)`, and call it after successful Docker create/rebuild with the runtime snapshot and config bundle hashes.
|
||||||
|
|
||||||
|
- [x] **Step 4: Run GREEN**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm exec jest --runTestsByPath test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
## Task 4: MySQL Timezone RED/GREEN
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Add: `test/app/typeorm-timezone-config.spec.ts`
|
||||||
|
- Modify: `src/app.module.ts`
|
||||||
|
- Modify: `src/runtime/config/runtime-config.types.ts`
|
||||||
|
- Modify: `src/runtime/config/runtime-config.service.ts`
|
||||||
|
|
||||||
|
- [x] **Step 1: Write failing tests**
|
||||||
|
|
||||||
|
Extract a small exported helper from `app.module.ts` or test the factory through metadata so the returned TypeORM options include:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
expect(options.timezone).toBe('+08:00');
|
||||||
|
```
|
||||||
|
|
||||||
|
Also assert `RuntimeConfigService.readDatabaseProfile()` reports `timezone: '+08:00'`.
|
||||||
|
|
||||||
|
- [x] **Step 2: Run RED**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm exec jest --runTestsByPath test/app/typeorm-timezone-config.spec.ts test/runtime/runtime-config.service.spec.ts --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because no DB timezone is configured or exposed.
|
||||||
|
|
||||||
|
- [x] **Step 3: Implement timezone config**
|
||||||
|
|
||||||
|
Set TypeORM MySQL `timezone` to `configService.get('DB_TIMEZONE') || '+08:00'`, and expose the same field from runtime health config.
|
||||||
|
|
||||||
|
- [x] **Step 4: Run GREEN**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm exec jest --runTestsByPath test/app/typeorm-timezone-config.spec.ts test/runtime/runtime-config.service.spec.ts --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
## Task 5: Image, SQL, Docs, And Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ci/napcat-desktop-cn/Dockerfile`
|
||||||
|
- Modify: `.env.example`
|
||||||
|
- Modify: `README.md`
|
||||||
|
- Modify: `API.md`
|
||||||
|
- Modify: `sql/qqbot-init.sql`
|
||||||
|
- Modify: `sql/refactor-v3/00-full-schema.sql`
|
||||||
|
- Modify: `sql/refactor-v3/99-verify.sql`
|
||||||
|
- Modify: `TASKS.md`
|
||||||
|
|
||||||
|
- [x] **Step 1: Keep derived image actually Chinese-locale capable**
|
||||||
|
|
||||||
|
Ensure the Dockerfile installs `locales`, generates `zh_CN.UTF-8`, and sets `LC_ALL=zh_CN.UTF-8`. The verification script must fail if `locale -a` lacks `zh_CN.utf8`.
|
||||||
|
|
||||||
|
- [x] **Step 2: Align SQL defaults and docs**
|
||||||
|
|
||||||
|
Document `DB_TIMEZONE=+08:00`, `QQBOT_NAPCAT_IMAGE` derived image usage, and device identity strategy names. Update SQL verify checks for `device_identity_id`, strategy columns, and profile tables.
|
||||||
|
|
||||||
|
- [x] **Step 3: Run focused validation**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm exec jest --runTestsByPath test/modules/qqbot/napcat/device-identity.spec.ts test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts test/app/typeorm-timezone-config.spec.ts test/runtime/runtime-config.service.spec.ts --runInBand
|
||||||
|
pnpm run typecheck
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all pass.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Online controlled repair after deploy**
|
||||||
|
|
||||||
|
After commit/push/deploy, run a scoped online repair:
|
||||||
|
|
||||||
|
1. Backup `napcat_device_identity`, `napcat_account_binding`, `napcat_runtime_profile`, `napcat_protocol_profile`, `napcat_login_session`.
|
||||||
|
2. Restart/recreate NapCat containers through the managed API path so code writes DB binding/profile rows and Docker machine-info.
|
||||||
|
3. Verify:
|
||||||
|
- `napcat_account_binding.device_identity_id` is not null for enabled accounts.
|
||||||
|
- profile tables have rows for enabled accounts.
|
||||||
|
- each running container has hostname `pc-<8 hex>`, Docker MAC `02:42:*`, `/etc/machine-id`, decoded `machine-info` MAC equal to Docker MAC, `TZ=Asia/Shanghai`, writable XDG runtime dir, and `locale -a` includes `zh_CN.utf8`.
|
||||||
|
- MySQL `NOW()` offset or API session rows no longer mix UTC and CST fields.
|
||||||
|
4. Ask user to scan one login QR if QQ requires new-device verification; then confirm QQ mobile page shows a non-unknown device name.
|
||||||
File diff suppressed because it is too large
Load Diff
@ -197,3 +197,10 @@ WHERE table_schema = DATABASE()
|
|||||||
AND table_name = 'napcat_device_identity'
|
AND table_name = 'napcat_device_identity'
|
||||||
AND column_name = 'mac_strategy'
|
AND column_name = 'mac_strategy'
|
||||||
AND column_type = 'varchar(64)';
|
AND column_type = 'varchar(64)';
|
||||||
|
|
||||||
|
SELECT 'column_napcat_account_binding_device_identity_id' AS check_name, COUNT(*) AS matched_rows
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'napcat_account_binding'
|
||||||
|
AND column_name = 'device_identity_id'
|
||||||
|
AND column_type = 'bigint';
|
||||||
|
|||||||
@ -22,6 +22,29 @@ import { QqbotPluginPlatformModule } from './modules/qqbot/plugin-platform/plugi
|
|||||||
import { WordpressMirrorModule } from './modules/wordpress/wordpress-mirror.module';
|
import { WordpressMirrorModule } from './modules/wordpress/wordpress-mirror.module';
|
||||||
import { RuntimeModule } from './runtime';
|
import { RuntimeModule } from './runtime';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds TypeORM MySQL options from runtime config with an explicit session timezone.
|
||||||
|
* @param configService - Nest ConfigService source for DB connection fields and DB_TIMEZONE override.
|
||||||
|
* @returns TypeORM MySQL options consumed by AppModule startup.
|
||||||
|
*/
|
||||||
|
export function buildTypeOrmOptions(configService: ConfigService) {
|
||||||
|
return {
|
||||||
|
type: 'mysql' as const,
|
||||||
|
host: configService.get('DB_HOST'),
|
||||||
|
port: configService.get('DB_PORT'),
|
||||||
|
username: configService.get('DB_USERNAME'),
|
||||||
|
password: configService.get('DB_PASSWORD'),
|
||||||
|
database: configService.get('DB_DATABASE'),
|
||||||
|
timezone: configService.get<string>('DB_TIMEZONE') || '+08:00',
|
||||||
|
synchronize: configService.get<string>('DB_SYNC') === 'true',
|
||||||
|
entities: [
|
||||||
|
__dirname + '/**/*.entity{.ts,.js}',
|
||||||
|
__dirname + '/**/*.entities{.ts,.js}',
|
||||||
|
],
|
||||||
|
subscribers: [__dirname + '/**/*.subscriber{.ts,.js}'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
@ -44,22 +67,8 @@ import { RuntimeModule } from './runtime';
|
|||||||
* 创建 模块依赖注入工厂产物。
|
* 创建 模块依赖注入工厂产物。
|
||||||
* @param configService - Nest ConfigService 依赖;使用 `get` 字段生成结果。
|
* @param configService - Nest ConfigService 依赖;使用 `get` 字段生成结果。
|
||||||
*/
|
*/
|
||||||
useFactory: async (configService: ConfigService) => {
|
useFactory: async (configService: ConfigService) =>
|
||||||
return {
|
buildTypeOrmOptions(configService),
|
||||||
type: 'mysql',
|
|
||||||
host: configService.get('DB_HOST'),
|
|
||||||
port: configService.get('DB_PORT'),
|
|
||||||
username: configService.get('DB_USERNAME'),
|
|
||||||
password: configService.get('DB_PASSWORD'),
|
|
||||||
database: configService.get('DB_DATABASE'),
|
|
||||||
synchronize: configService.get<string>('DB_SYNC') === 'true',
|
|
||||||
entities: [
|
|
||||||
__dirname + '/**/*.entity{.ts,.js}',
|
|
||||||
__dirname + '/**/*.entities{.ts,.js}',
|
|
||||||
],
|
|
||||||
subscribers: [__dirname + '/**/*.subscriber{.ts,.js}'],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
}),
|
}),
|
||||||
MinioModule.registerAsync({
|
MinioModule.registerAsync({
|
||||||
|
|||||||
@ -1,6 +1,35 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Optional } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
import type { NapcatRuntimeProfileSnapshot } from '../../domain/runtime/napcat-profile.types';
|
import type { NapcatRuntimeProfileSnapshot } from '../../domain/runtime/napcat-profile.types';
|
||||||
|
import { NapcatProtocolProfile } from '../../infrastructure/persistence/napcat-protocol-profile.entity';
|
||||||
|
import { NapcatRuntimeProfile } from '../../infrastructure/persistence/napcat-runtime-profile.entity';
|
||||||
|
|
||||||
|
type RecordPlannedProfilesInput = {
|
||||||
|
accountId: string;
|
||||||
|
containerId?: string;
|
||||||
|
dataDir: string;
|
||||||
|
deviceIdentity?: {
|
||||||
|
deviceIdentityId?: string;
|
||||||
|
hostname: string;
|
||||||
|
hostnameStrategy?: string;
|
||||||
|
machineInfoPath: string;
|
||||||
|
macAddress: string;
|
||||||
|
macStrategy?: string;
|
||||||
|
};
|
||||||
|
protocolProfile: {
|
||||||
|
napcatConfigHash?: string;
|
||||||
|
napcatConfigJson?: Record<string, unknown>;
|
||||||
|
o3HookGrayEnabled?: boolean;
|
||||||
|
o3HookMode?: 0 | 1;
|
||||||
|
onebotConfigHash?: string;
|
||||||
|
onebotConfigJson?: Record<string, unknown>;
|
||||||
|
packetBackend?: string;
|
||||||
|
packetServer?: string;
|
||||||
|
};
|
||||||
|
runtimeProfile: NapcatRuntimeProfileSnapshot;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NapcatRuntimeProfileService {
|
export class NapcatRuntimeProfileService {
|
||||||
@ -8,7 +37,15 @@ export class NapcatRuntimeProfileService {
|
|||||||
* Initializes the profile resolver used before managed runtime script generation.
|
* Initializes the profile resolver used before managed runtime script generation.
|
||||||
* @param configService - Nest config provider that supplies image ref, UID/GID, shm size, and profile version.
|
* @param configService - Nest config provider that supplies image ref, UID/GID, shm size, and profile version.
|
||||||
*/
|
*/
|
||||||
constructor(private readonly configService: ConfigService) {}
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
@Optional()
|
||||||
|
@InjectRepository(NapcatRuntimeProfile)
|
||||||
|
private readonly runtimeProfileRepository?: Repository<NapcatRuntimeProfile>,
|
||||||
|
@Optional()
|
||||||
|
@InjectRepository(NapcatProtocolProfile)
|
||||||
|
private readonly protocolProfileRepository?: Repository<NapcatProtocolProfile>,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the runtime profile for an account-owned NapCat container.
|
* Resolves the runtime profile for an account-owned NapCat container.
|
||||||
@ -45,6 +82,95 @@ export class NapcatRuntimeProfileService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the planned runtime and protocol profile after Docker accepts a managed container rebuild.
|
||||||
|
* @param input - Account/container identity, generated runtime profile, and config hashes that establish the expected state before live inspection.
|
||||||
|
*/
|
||||||
|
async recordPlannedProfiles(input: RecordPlannedProfilesInput) {
|
||||||
|
const accountId = `${input.accountId || ''}`.trim();
|
||||||
|
if (!accountId) return;
|
||||||
|
|
||||||
|
if (this.runtimeProfileRepository) {
|
||||||
|
await this.runtimeProfileRepository.save(
|
||||||
|
this.runtimeProfileRepository.create({
|
||||||
|
accountId,
|
||||||
|
baseImageDigest: null,
|
||||||
|
containerId: input.containerId || null,
|
||||||
|
desktopProfileVersion: input.runtimeProfile.desktopProfileVersion,
|
||||||
|
deviceIdentityId:
|
||||||
|
input.deviceIdentity?.deviceIdentityId ||
|
||||||
|
input.runtimeProfile.deviceIdentityId ||
|
||||||
|
null,
|
||||||
|
fontconfigEvidence: null,
|
||||||
|
hostnameStrategy:
|
||||||
|
input.deviceIdentity?.hostnameStrategy ||
|
||||||
|
'qqnt-visible-hostname-v1',
|
||||||
|
imageDigest: null,
|
||||||
|
imageRef: input.runtimeProfile.imageRef,
|
||||||
|
lastCheckEvidence: {
|
||||||
|
dataDir: input.dataDir,
|
||||||
|
deviceIdentityId:
|
||||||
|
input.deviceIdentity?.deviceIdentityId ||
|
||||||
|
input.runtimeProfile.deviceIdentityId ||
|
||||||
|
null,
|
||||||
|
hostname: input.deviceIdentity?.hostname || null,
|
||||||
|
machineInfoPath: input.deviceIdentity?.machineInfoPath || null,
|
||||||
|
macAddress: input.deviceIdentity?.macAddress || null,
|
||||||
|
},
|
||||||
|
lastCheckedAt: null,
|
||||||
|
locale: input.runtimeProfile.locale,
|
||||||
|
localeAvailable: false,
|
||||||
|
macStrategy:
|
||||||
|
input.deviceIdentity?.macStrategy || 'docker-bridge-mac-v1',
|
||||||
|
migrateDeviceIdentity: !!input.deviceIdentity,
|
||||||
|
persistCache: input.runtimeProfile.persistCache,
|
||||||
|
persistLocalShare: input.runtimeProfile.persistLocalShare,
|
||||||
|
persistLogs: input.runtimeProfile.persistLogs,
|
||||||
|
profileStatus: 'pending',
|
||||||
|
profileVersion: this.getString(
|
||||||
|
'QQBOT_NAPCAT_PROFILE_VERSION',
|
||||||
|
'napcat-runtime-profile-v1',
|
||||||
|
),
|
||||||
|
runtimeGid: input.runtimeProfile.runtimeGid,
|
||||||
|
runtimeUid: input.runtimeProfile.runtimeUid,
|
||||||
|
shmSize: input.runtimeProfile.shmSize,
|
||||||
|
timezoneEvidence: {
|
||||||
|
expectedTimezone: input.runtimeProfile.timezone,
|
||||||
|
},
|
||||||
|
xdgCacheHome: input.runtimeProfile.xdgCacheHome,
|
||||||
|
xdgConfigHome: input.runtimeProfile.xdgConfigHome,
|
||||||
|
xdgDataHome: input.runtimeProfile.xdgDataHome,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.protocolProfileRepository) {
|
||||||
|
await this.protocolProfileRepository.save(
|
||||||
|
this.protocolProfileRepository.create({
|
||||||
|
accountId,
|
||||||
|
containerId: input.containerId || null,
|
||||||
|
lastCheckEvidence: {
|
||||||
|
configSource: 'managed-create-script',
|
||||||
|
},
|
||||||
|
lastCheckedAt: null,
|
||||||
|
napcatConfigHash: input.protocolProfile.napcatConfigHash || null,
|
||||||
|
napcatConfigJson: input.protocolProfile.napcatConfigJson || null,
|
||||||
|
o3HookGrayEnabled: !!input.protocolProfile.o3HookGrayEnabled,
|
||||||
|
o3HookMode: input.protocolProfile.o3HookMode ?? 1,
|
||||||
|
onebotConfigHash: input.protocolProfile.onebotConfigHash || null,
|
||||||
|
onebotConfigJson: input.protocolProfile.onebotConfigJson || null,
|
||||||
|
packetBackend: input.protocolProfile.packetBackend || 'auto',
|
||||||
|
packetServer: input.protocolProfile.packetServer || '',
|
||||||
|
profileStatus: 'pending',
|
||||||
|
profileVersion: this.getString(
|
||||||
|
'QQBOT_NAPCAT_PROTOCOL_PROFILE_VERSION',
|
||||||
|
'napcat-protocol-profile-v1',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads a trimmed string config value for profile generation.
|
* Reads a trimmed string config value for profile generation.
|
||||||
* @param key - Environment key that controls NapCat runtime profile generation.
|
* @param key - Environment key that controls NapCat runtime profile generation.
|
||||||
|
|||||||
@ -1,12 +1,17 @@
|
|||||||
import type { NapcatDeviceIdentity } from '../../persistence/napcat-device-identity.entity';
|
import type { NapcatDeviceIdentity } from '../../persistence/napcat-device-identity.entity';
|
||||||
|
|
||||||
export type NapcatDockerDeviceOptions = {
|
export type NapcatDockerDeviceOptions = {
|
||||||
|
accountId: string;
|
||||||
dataDir: string;
|
dataDir: string;
|
||||||
deviceEnvPath: string;
|
deviceEnvPath: string;
|
||||||
deviceIdentityId?: string;
|
deviceIdentityId?: string;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
machineIdPath: string;
|
machineIdPath: string;
|
||||||
|
machineInfoPath: string;
|
||||||
macAddress: string;
|
macAddress: string;
|
||||||
|
macAddressHyphen: string;
|
||||||
|
hostnameStrategy?: string;
|
||||||
|
macStrategy?: string;
|
||||||
runFlags: string[];
|
runFlags: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -18,16 +23,30 @@ export type NapcatDockerDeviceOptions = {
|
|||||||
export function toNapcatDockerDeviceOptions(
|
export function toNapcatDockerDeviceOptions(
|
||||||
identity: Pick<
|
identity: Pick<
|
||||||
NapcatDeviceIdentity,
|
NapcatDeviceIdentity,
|
||||||
'dataDir' | 'hostname' | 'id' | 'machineIdPath' | 'macAddress'
|
| 'accountId'
|
||||||
|
| 'dataDir'
|
||||||
|
| 'hostname'
|
||||||
|
| 'hostnameStrategy'
|
||||||
|
| 'id'
|
||||||
|
| 'machineIdPath'
|
||||||
|
| 'macAddress'
|
||||||
|
| 'macStrategy'
|
||||||
>,
|
>,
|
||||||
): NapcatDockerDeviceOptions {
|
): NapcatDockerDeviceOptions {
|
||||||
|
const machineInfoPath = `${identity.dataDir}/QQ/nt_qq/global/nt_data/msf/machine-info`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
accountId: identity.accountId,
|
||||||
dataDir: identity.dataDir,
|
dataDir: identity.dataDir,
|
||||||
deviceEnvPath: `${identity.dataDir}/device.env`,
|
deviceEnvPath: `${identity.dataDir}/device.env`,
|
||||||
deviceIdentityId: identity.id,
|
deviceIdentityId: identity.id,
|
||||||
hostname: identity.hostname,
|
hostname: identity.hostname,
|
||||||
|
hostnameStrategy: identity.hostnameStrategy,
|
||||||
machineIdPath: identity.machineIdPath,
|
machineIdPath: identity.machineIdPath,
|
||||||
|
machineInfoPath,
|
||||||
macAddress: identity.macAddress,
|
macAddress: identity.macAddress,
|
||||||
|
macAddressHyphen: identity.macAddress.replace(/:/g, '-').toLowerCase(),
|
||||||
|
macStrategy: identity.macStrategy,
|
||||||
runFlags: [
|
runFlags: [
|
||||||
'--hostname "$NAPCAT_HOSTNAME"',
|
'--hostname "$NAPCAT_HOSTNAME"',
|
||||||
'--mac-address "$NAPCAT_MAC_ADDRESS"',
|
'--mac-address "$NAPCAT_MAC_ADDRESS"',
|
||||||
|
|||||||
@ -177,6 +177,8 @@ export class QqbotNapcatContainerService {
|
|||||||
);
|
);
|
||||||
await this.createRemoteDockerContainer({
|
await this.createRemoteDockerContainer({
|
||||||
account,
|
account,
|
||||||
|
accountId: deviceIdentity?.accountId,
|
||||||
|
containerId: container.id,
|
||||||
dataDir:
|
dataDir:
|
||||||
deviceIdentity?.dataDir ||
|
deviceIdentity?.dataDir ||
|
||||||
container.dataDir ||
|
container.dataDir ||
|
||||||
@ -246,6 +248,24 @@ export class QqbotNapcatContainerService {
|
|||||||
return toNapcatDockerDeviceOptions(identity);
|
return toNapcatDockerDeviceOptions(identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the device identity id that belongs to an account/container binding.
|
||||||
|
* @param accountId - Internal QQBot account id used to select the persistent device identity.
|
||||||
|
* @param containerId - Managed NapCat container id that should be stored on the identity row.
|
||||||
|
* @returns Device identity id when the identity service is available.
|
||||||
|
*/
|
||||||
|
private async resolveBindingDeviceIdentityId(
|
||||||
|
accountId: string,
|
||||||
|
containerId: string,
|
||||||
|
) {
|
||||||
|
if (!this.deviceIdentityService) return undefined;
|
||||||
|
const identity = await this.deviceIdentityService.resolveForAccount({
|
||||||
|
accountId,
|
||||||
|
containerId,
|
||||||
|
});
|
||||||
|
return identity.id;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行 NapCat 登录运行态流程。
|
* 执行 NapCat 登录运行态流程。
|
||||||
* @param container - container 输入;使用 `id` 字段生成结果。
|
* @param container - container 输入;使用 `id` 字段生成结果。
|
||||||
@ -611,6 +631,10 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
|
|||||||
{ accountId, isDeleted: false },
|
{ accountId, isDeleted: false },
|
||||||
{ isPrimary: false },
|
{ isPrimary: false },
|
||||||
);
|
);
|
||||||
|
const deviceIdentityId = await this.resolveBindingDeviceIdentityId(
|
||||||
|
accountId,
|
||||||
|
containerId,
|
||||||
|
);
|
||||||
|
|
||||||
const existing = await this.bindingRepository.findOne({
|
const existing = await this.bindingRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
@ -627,6 +651,7 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
|
|||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
lastLoginAt: new Date(),
|
lastLoginAt: new Date(),
|
||||||
remark: '',
|
remark: '',
|
||||||
|
...(deviceIdentityId ? { deviceIdentityId } : {}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await this.removeOtherAccountContainers(accountId, containerId);
|
await this.removeOtherAccountContainers(accountId, containerId);
|
||||||
@ -638,6 +663,7 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
|
|||||||
accountId,
|
accountId,
|
||||||
bindStatus: 'bound',
|
bindStatus: 'bound',
|
||||||
containerId,
|
containerId,
|
||||||
|
...(deviceIdentityId ? { deviceIdentityId } : {}),
|
||||||
isPrimary: true,
|
isPrimary: true,
|
||||||
lastLoginAt: new Date(),
|
lastLoginAt: new Date(),
|
||||||
remark: '',
|
remark: '',
|
||||||
@ -1319,6 +1345,8 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
|
|||||||
try {
|
try {
|
||||||
await this.createRemoteDockerContainer({
|
await this.createRemoteDockerContainer({
|
||||||
account: selfId,
|
account: selfId,
|
||||||
|
accountId,
|
||||||
|
containerId: container.id,
|
||||||
dataDir,
|
dataDir,
|
||||||
deviceIdentity,
|
deviceIdentity,
|
||||||
image,
|
image,
|
||||||
@ -1337,11 +1365,15 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (accountId && this.deviceIdentityService) {
|
if (accountId && this.deviceIdentityService) {
|
||||||
await this.deviceIdentityService.resolveForAccount({
|
const identity = await this.deviceIdentityService.resolveForAccount({
|
||||||
accountId,
|
accountId,
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
selfId,
|
selfId,
|
||||||
});
|
});
|
||||||
|
await this.bindingRepository.update(
|
||||||
|
{ accountId, containerId: container.id, isDeleted: false },
|
||||||
|
{ deviceIdentityId: identity.id },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@ -1370,6 +1402,8 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
|
|||||||
*/
|
*/
|
||||||
private async createRemoteDockerContainer(input: {
|
private async createRemoteDockerContainer(input: {
|
||||||
account?: string;
|
account?: string;
|
||||||
|
accountId?: string;
|
||||||
|
containerId?: string;
|
||||||
dataDir: string;
|
dataDir: string;
|
||||||
deviceIdentity?: NapcatDockerDeviceOptions;
|
deviceIdentity?: NapcatDockerDeviceOptions;
|
||||||
image: string;
|
image: string;
|
||||||
@ -1382,6 +1416,7 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
|
|||||||
}) {
|
}) {
|
||||||
const script = this.buildRemoteCreateScript(input);
|
const script = this.buildRemoteCreateScript(input);
|
||||||
await this.runProcess('ssh', [...this.getSshArgs(), 'sh -s'], script);
|
await this.runProcess('ssh', [...this.getSshArgs(), 'sh -s'], script);
|
||||||
|
await this.recordPlannedProfiles(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1390,6 +1425,7 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
|
|||||||
*/
|
*/
|
||||||
private buildRemoteCreateScript(input: {
|
private buildRemoteCreateScript(input: {
|
||||||
account?: string;
|
account?: string;
|
||||||
|
accountId?: string;
|
||||||
containerId?: string;
|
containerId?: string;
|
||||||
dataDir: string;
|
dataDir: string;
|
||||||
deviceIdentity?: NapcatDockerDeviceOptions;
|
deviceIdentity?: NapcatDockerDeviceOptions;
|
||||||
@ -1433,20 +1469,36 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
|
|||||||
? [
|
? [
|
||||||
`NAPCAT_HOSTNAME=${this.sh(input.deviceIdentity.hostname)}`,
|
`NAPCAT_HOSTNAME=${this.sh(input.deviceIdentity.hostname)}`,
|
||||||
`NAPCAT_MAC_ADDRESS=${this.sh(input.deviceIdentity.macAddress)}`,
|
`NAPCAT_MAC_ADDRESS=${this.sh(input.deviceIdentity.macAddress)}`,
|
||||||
|
`NAPCAT_MAC_HYPHEN=${this.sh(input.deviceIdentity.macAddressHyphen)}`,
|
||||||
`MACHINE_ID_PATH=${this.sh(input.deviceIdentity.machineIdPath)}`,
|
`MACHINE_ID_PATH=${this.sh(input.deviceIdentity.machineIdPath)}`,
|
||||||
|
`MACHINE_INFO_PATH=${this.sh(input.deviceIdentity.machineInfoPath)}`,
|
||||||
`DEVICE_ENV_PATH=${this.sh(input.deviceIdentity.deviceEnvPath)}`,
|
`DEVICE_ENV_PATH=${this.sh(input.deviceIdentity.deviceEnvPath)}`,
|
||||||
].join('\n') + '\n'
|
].join('\n') + '\n'
|
||||||
: '';
|
: '';
|
||||||
const devicePrepareScript = input.deviceIdentity
|
const devicePrepareScript = input.deviceIdentity
|
||||||
? `
|
? `
|
||||||
|
mkdir -p "$(dirname "$DEVICE_ENV_PATH")" "$(dirname "$MACHINE_INFO_PATH")"
|
||||||
cat > "$DEVICE_ENV_PATH" <<EOF
|
cat > "$DEVICE_ENV_PATH" <<EOF
|
||||||
NAPCAT_HOSTNAME=$NAPCAT_HOSTNAME
|
NAPCAT_HOSTNAME=$NAPCAT_HOSTNAME
|
||||||
NAPCAT_MAC_ADDRESS=$NAPCAT_MAC_ADDRESS
|
NAPCAT_MAC_ADDRESS=$NAPCAT_MAC_ADDRESS
|
||||||
|
NAPCAT_MAC_HYPHEN=$NAPCAT_MAC_HYPHEN
|
||||||
MACHINE_ID_PATH=$MACHINE_ID_PATH
|
MACHINE_ID_PATH=$MACHINE_ID_PATH
|
||||||
|
MACHINE_INFO_PATH=$MACHINE_INFO_PATH
|
||||||
EOF
|
EOF
|
||||||
if [ ! -s "$MACHINE_ID_PATH" ]; then
|
if [ ! -s "$MACHINE_ID_PATH" ]; then
|
||||||
printf '%s' "$NAME" | sha256sum | cut -c 1-32 > "$MACHINE_ID_PATH"
|
printf '%s' "$NAME" | sha256sum | cut -c 1-32 > "$MACHINE_ID_PATH"
|
||||||
fi
|
fi
|
||||||
|
if [ -s "$MACHINE_INFO_PATH" ]; then
|
||||||
|
CURRENT_MACHINE_INFO_MAC="$(dd if="$MACHINE_INFO_PATH" bs=1 skip=4 2>/dev/null | tr 'A-Za-z' 'N-ZA-Mn-za-m' || true)"
|
||||||
|
if [ "$CURRENT_MACHINE_INFO_MAC" != "$NAPCAT_MAC_HYPHEN" ]; then
|
||||||
|
cp "$MACHINE_INFO_PATH" "$MACHINE_INFO_PATH.bak.$(date +%Y%m%d%H%M%S)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
MACHINE_INFO_TMP="$MACHINE_INFO_PATH.tmp"
|
||||||
|
printf '\\000\\000\\000\\021' > "$MACHINE_INFO_TMP"
|
||||||
|
printf '%s' "$NAPCAT_MAC_HYPHEN" | tr 'A-Za-z' 'N-ZA-Mn-za-m' >> "$MACHINE_INFO_TMP"
|
||||||
|
mv "$MACHINE_INFO_TMP" "$MACHINE_INFO_PATH"
|
||||||
|
chmod 644 "$MACHINE_INFO_PATH"
|
||||||
`
|
`
|
||||||
: '';
|
: '';
|
||||||
const deviceRunFlags = input.deviceIdentity
|
const deviceRunFlags = input.deviceIdentity
|
||||||
@ -1467,8 +1519,9 @@ NAPCAT_SHM_SIZE=${this.sh(runtimeProfile.shmSize)}
|
|||||||
${accountHeader}
|
${accountHeader}
|
||||||
${passwordHeader}
|
${passwordHeader}
|
||||||
${deviceHeader}
|
${deviceHeader}
|
||||||
mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs" "$DATA_DIR/cache" "$DATA_DIR/local-share"
|
mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs" "$DATA_DIR/cache" "$DATA_DIR/local-share" "$DATA_DIR/runtime"
|
||||||
chmod 700 "$DATA_DIR"
|
chmod 700 "$DATA_DIR"
|
||||||
|
chmod 700 "$DATA_DIR/runtime"
|
||||||
${devicePrepareScript}
|
${devicePrepareScript}
|
||||||
|
|
||||||
${configWriteScript}
|
${configWriteScript}
|
||||||
@ -1497,11 +1550,74 @@ ${accountRunFlag}${passwordRunFlag}${deviceRunFlags} -p "$PORT:6099" \\
|
|||||||
-v "$DATA_DIR/plugins:/app/napcat/plugins" \\
|
-v "$DATA_DIR/plugins:/app/napcat/plugins" \\
|
||||||
-v "$DATA_DIR/cache:/app/.cache" \\
|
-v "$DATA_DIR/cache:/app/.cache" \\
|
||||||
-v "$DATA_DIR/local-share:/app/.local/share" \\
|
-v "$DATA_DIR/local-share:/app/.local/share" \\
|
||||||
|
-v "$DATA_DIR/runtime:/tmp/runtime-napcat" \\
|
||||||
-v "$DATA_DIR/logs:/app/napcat/logs" \\
|
-v "$DATA_DIR/logs:/app/napcat/logs" \\
|
||||||
"$IMAGE" >/dev/null
|
"$IMAGE" >/dev/null
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the expected runtime and protocol state for a successfully created managed container.
|
||||||
|
* @param input - Docker creation request whose identity and config values define the planned profile.
|
||||||
|
*/
|
||||||
|
private async recordPlannedProfiles(input: {
|
||||||
|
account?: string;
|
||||||
|
accountId?: string;
|
||||||
|
containerId?: string;
|
||||||
|
dataDir: string;
|
||||||
|
deviceIdentity?: NapcatDockerDeviceOptions;
|
||||||
|
image: string;
|
||||||
|
loginPassword?: string;
|
||||||
|
name: string;
|
||||||
|
port: number;
|
||||||
|
reverseWsUrl: string;
|
||||||
|
skipPull?: boolean;
|
||||||
|
token: string;
|
||||||
|
}) {
|
||||||
|
const accountId =
|
||||||
|
this.toolsService.toTrimmedString(input.accountId) ||
|
||||||
|
this.toolsService.toTrimmedString(input.deviceIdentity?.accountId);
|
||||||
|
if (!accountId || !input.deviceIdentity) return;
|
||||||
|
|
||||||
|
const account = `${input.account || ''}`.trim();
|
||||||
|
const runtimeProfile = this.runtimeProfileService.resolveRuntimeProfile({
|
||||||
|
accountId,
|
||||||
|
containerId: input.containerId,
|
||||||
|
dataDir: input.dataDir,
|
||||||
|
deviceIdentityId: input.deviceIdentity.deviceIdentityId,
|
||||||
|
});
|
||||||
|
const configBundle = this.configWriterService.buildConfigFiles({
|
||||||
|
account,
|
||||||
|
reverseWsUrl: input.reverseWsUrl,
|
||||||
|
token: input.token,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.runtimeProfileService.recordPlannedProfiles({
|
||||||
|
accountId,
|
||||||
|
containerId: input.containerId,
|
||||||
|
dataDir: input.dataDir,
|
||||||
|
deviceIdentity: {
|
||||||
|
deviceIdentityId: input.deviceIdentity.deviceIdentityId,
|
||||||
|
hostname: input.deviceIdentity.hostname,
|
||||||
|
hostnameStrategy: input.deviceIdentity.hostnameStrategy,
|
||||||
|
machineInfoPath: input.deviceIdentity.machineInfoPath,
|
||||||
|
macAddress: input.deviceIdentity.macAddress,
|
||||||
|
macStrategy: input.deviceIdentity.macStrategy,
|
||||||
|
},
|
||||||
|
protocolProfile: {
|
||||||
|
napcatConfigHash: configBundle.napcatConfigHash,
|
||||||
|
napcatConfigJson: configBundle.napcatConfig,
|
||||||
|
o3HookGrayEnabled: false,
|
||||||
|
o3HookMode: configBundle.napcatConfig.o3HookMode,
|
||||||
|
onebotConfigHash: configBundle.onebotConfigHash,
|
||||||
|
onebotConfigJson: configBundle.onebotConfig,
|
||||||
|
packetBackend: configBundle.napcatConfig.packetBackend,
|
||||||
|
packetServer: configBundle.napcatConfig.packetServer,
|
||||||
|
},
|
||||||
|
runtimeProfile,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders NapCat config files as shell here-doc writes under the account config directory.
|
* Renders NapCat config files as shell here-doc writes under the account config directory.
|
||||||
* @param files - Config files generated by `NapcatConfigWriterService` for this container.
|
* @param files - Config files generated by `NapcatConfigWriterService` for this container.
|
||||||
|
|||||||
@ -4,12 +4,11 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { ensureSnowflakeId } from '@/common';
|
import { ensureSnowflakeId } from '@/common';
|
||||||
import {
|
|
||||||
isRejectedVirtualMacPrefix,
|
|
||||||
NAPCAT_PHYSICAL_OUI_PREFIXES,
|
|
||||||
} from '../../../domain/runtime/napcat-physical-oui-catalog';
|
|
||||||
import { NapcatDeviceIdentity } from '../../persistence/napcat-device-identity.entity';
|
import { NapcatDeviceIdentity } from '../../persistence/napcat-device-identity.entity';
|
||||||
|
|
||||||
|
const QQNT_VISIBLE_HOSTNAME_STRATEGY = 'qqnt-visible-hostname-v1';
|
||||||
|
const QQNT_DOCKER_BRIDGE_MAC_STRATEGY = 'docker-bridge-mac-v1';
|
||||||
|
|
||||||
type ResolveNapcatDeviceIdentityInput = {
|
type ResolveNapcatDeviceIdentityInput = {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
containerId?: string;
|
containerId?: string;
|
||||||
@ -62,11 +61,13 @@ export class NapcatDeviceIdentityService {
|
|||||||
accountId,
|
accountId,
|
||||||
containerId: input.containerId || null,
|
containerId: input.containerId || null,
|
||||||
dataDir,
|
dataDir,
|
||||||
hostname: this.buildDesktopHostname(`${accountId}:${input.selfId || ''}`),
|
hostname: this.buildQqntVisibleHostname(
|
||||||
hostnameStrategy: 'desktop-hostname-v1',
|
`${accountId}:${input.selfId || ''}`,
|
||||||
|
),
|
||||||
|
hostnameStrategy: QQNT_VISIBLE_HOSTNAME_STRATEGY,
|
||||||
lastLoginEvidence: null,
|
lastLoginEvidence: null,
|
||||||
macAddress: this.buildPhysicalMacAddress(accountId, containerName),
|
macAddress: this.buildDockerBridgeMacAddress(accountId, containerName),
|
||||||
macStrategy: 'physical-oui-v1',
|
macStrategy: QQNT_DOCKER_BRIDGE_MAC_STRATEGY,
|
||||||
machineIdPath: `${dataDir}/machine-id`,
|
machineIdPath: `${dataDir}/machine-id`,
|
||||||
verificationStatus: 'pending',
|
verificationStatus: 'pending',
|
||||||
});
|
});
|
||||||
@ -91,35 +92,33 @@ export class NapcatDeviceIdentityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a stable desktop-like hostname that avoids QQ numbers and container naming terms.
|
* Builds a short stable hostname that survives the upstream Docker entrypoint rewrite and remains visible to QQNT.
|
||||||
* @param seed - Account/self-id seed used only for deterministic hashing, never copied into visible hostname text.
|
* @param seed - Account/self-id seed used only for deterministic hashing, never copied into visible hostname text.
|
||||||
*/
|
*/
|
||||||
private buildDesktopHostname(seed: string) {
|
private buildQqntVisibleHostname(seed: string) {
|
||||||
const hash = createHash('sha256').update(seed).digest('hex');
|
const hash = createHash('sha256').update(seed).digest('hex');
|
||||||
return `ubuntu-pc-${hash.slice(0, 10)}`;
|
return `pc-${hash.slice(0, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a stable MAC using a physical-device-style OUI prefix.
|
* Builds a stable Docker bridge MAC that can be mirrored into QQNT machine-info.
|
||||||
* @param accountId - Account id used as a deterministic seed, not as visible output.
|
* @param accountId - Account id used as a deterministic seed, not as visible output.
|
||||||
* @param containerName - Container name mixed into the deterministic seed.
|
* @param containerName - Container name mixed into the deterministic seed.
|
||||||
*/
|
*/
|
||||||
private buildPhysicalMacAddress(accountId: string, containerName: string) {
|
private buildDockerBridgeMacAddress(
|
||||||
|
accountId: string,
|
||||||
|
containerName: string,
|
||||||
|
) {
|
||||||
const hash = createHash('sha256')
|
const hash = createHash('sha256')
|
||||||
.update(`${accountId}:${containerName}:physical-oui-v1`)
|
.update(`${accountId}:${containerName}:${QQNT_DOCKER_BRIDGE_MAC_STRATEGY}`)
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
const prefixIndex =
|
const suffix = [
|
||||||
Number.parseInt(hash.slice(0, 4), 16) %
|
hash.slice(0, 2),
|
||||||
NAPCAT_PHYSICAL_OUI_PREFIXES.length;
|
hash.slice(2, 4),
|
||||||
const prefix = NAPCAT_PHYSICAL_OUI_PREFIXES[prefixIndex];
|
hash.slice(4, 6),
|
||||||
const suffix = [hash.slice(4, 6), hash.slice(6, 8), hash.slice(8, 10)];
|
hash.slice(6, 8),
|
||||||
const mac = `${prefix}:${suffix.join(':')}`.toLowerCase();
|
];
|
||||||
|
return `02:42:${suffix.join(':')}`.toLowerCase();
|
||||||
if (isRejectedVirtualMacPrefix(mac)) {
|
|
||||||
throw new Error(`Rejected generated virtual MAC prefix: ${mac}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return mac;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -135,16 +134,20 @@ export class NapcatDeviceIdentityService {
|
|||||||
selfId: string;
|
selfId: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const nextHostname = this.buildDesktopHostname(
|
const nextHostname = this.buildQqntVisibleHostname(
|
||||||
`${input.accountId}:${input.selfId}`,
|
`${input.accountId}:${input.selfId}`,
|
||||||
);
|
);
|
||||||
const nextMacAddress = this.buildPhysicalMacAddress(
|
const nextMacAddress = this.buildDockerBridgeMacAddress(
|
||||||
input.accountId,
|
input.accountId,
|
||||||
input.containerName,
|
input.containerName,
|
||||||
);
|
);
|
||||||
const needsMigration =
|
const needsMigration =
|
||||||
identity.hostname !== nextHostname ||
|
identity.hostnameStrategy !== QQNT_VISIBLE_HOSTNAME_STRATEGY ||
|
||||||
isRejectedVirtualMacPrefix(identity.macAddress);
|
identity.macStrategy !== QQNT_DOCKER_BRIDGE_MAC_STRATEGY ||
|
||||||
|
!/^pc-[a-f0-9]{8}$/.test(identity.hostname || '') ||
|
||||||
|
!/^02:42:([0-9a-f]{2}:){3}[0-9a-f]{2}$/i.test(
|
||||||
|
identity.macAddress || '',
|
||||||
|
);
|
||||||
|
|
||||||
if (!needsMigration) {
|
if (!needsMigration) {
|
||||||
return;
|
return;
|
||||||
@ -154,10 +157,10 @@ export class NapcatDeviceIdentityService {
|
|||||||
migration: {
|
migration: {
|
||||||
fromHostname: identity.hostname,
|
fromHostname: identity.hostname,
|
||||||
fromMacAddress: identity.macAddress,
|
fromMacAddress: identity.macAddress,
|
||||||
strategy: 'physical-oui-v1',
|
strategy: QQNT_DOCKER_BRIDGE_MAC_STRATEGY,
|
||||||
toHostname: nextHostname,
|
toHostname: nextHostname,
|
||||||
toMacAddress: nextMacAddress,
|
toMacAddress: nextMacAddress,
|
||||||
trigger: 'legacy-docker-identity-upgrade',
|
trigger: 'qqnt-device-name-regression-repair',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -165,18 +168,18 @@ export class NapcatDeviceIdentityService {
|
|||||||
{ id: identity.id },
|
{ id: identity.id },
|
||||||
{
|
{
|
||||||
hostname: nextHostname,
|
hostname: nextHostname,
|
||||||
hostnameStrategy: 'desktop-hostname-v1',
|
hostnameStrategy: QQNT_VISIBLE_HOSTNAME_STRATEGY,
|
||||||
lastLoginEvidence: migrationEvidence,
|
lastLoginEvidence: migrationEvidence,
|
||||||
macAddress: nextMacAddress,
|
macAddress: nextMacAddress,
|
||||||
macStrategy: 'physical-oui-v1',
|
macStrategy: QQNT_DOCKER_BRIDGE_MAC_STRATEGY,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
Object.assign(identity, {
|
Object.assign(identity, {
|
||||||
hostname: nextHostname,
|
hostname: nextHostname,
|
||||||
hostnameStrategy: 'desktop-hostname-v1',
|
hostnameStrategy: QQNT_VISIBLE_HOSTNAME_STRATEGY,
|
||||||
lastLoginEvidence: migrationEvidence,
|
lastLoginEvidence: migrationEvidence,
|
||||||
macAddress: nextMacAddress,
|
macAddress: nextMacAddress,
|
||||||
macStrategy: 'physical-oui-v1',
|
macStrategy: QQNT_DOCKER_BRIDGE_MAC_STRATEGY,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -23,6 +23,7 @@ const REQUIRED_CONFIG_KEYS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const OPTIONAL_CONFIG_CHECKS: ReadonlyArray<string | readonly string[]> = [
|
const OPTIONAL_CONFIG_CHECKS: ReadonlyArray<string | readonly string[]> = [
|
||||||
|
'DB_TIMEZONE',
|
||||||
'MINIO_ENDPOINT',
|
'MINIO_ENDPOINT',
|
||||||
'MINIO_PORT',
|
'MINIO_PORT',
|
||||||
'MINIO_ACCESS_KEY',
|
'MINIO_ACCESS_KEY',
|
||||||
@ -95,6 +96,7 @@ export class RuntimeConfigService {
|
|||||||
database: this.getString('DB_DATABASE'),
|
database: this.getString('DB_DATABASE'),
|
||||||
username: this.getString('DB_USERNAME'),
|
username: this.getString('DB_USERNAME'),
|
||||||
synchronize: this.getBoolean('DB_SYNC', false),
|
synchronize: this.getBoolean('DB_SYNC', false),
|
||||||
|
timezone: this.getString('DB_TIMEZONE', '+08:00'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export interface RuntimeDatabaseConfig {
|
|||||||
database: string;
|
database: string;
|
||||||
username: string;
|
username: string;
|
||||||
synchronize: boolean;
|
synchronize: boolean;
|
||||||
|
timezone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuntimeLokiConfig {
|
export interface RuntimeLokiConfig {
|
||||||
|
|||||||
41
test/app/typeorm-timezone-config.spec.ts
Normal file
41
test/app/typeorm-timezone-config.spec.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { buildTypeOrmOptions } from '../../src/app.module';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a minimal ConfigService test double for TypeORM option generation.
|
||||||
|
* @param values - Environment-like values returned by ConfigService.get().
|
||||||
|
*/
|
||||||
|
function createConfig(values: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
get: jest.fn((key: string) => values[key]),
|
||||||
|
} as unknown as ConfigService;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppModule TypeORM options', () => {
|
||||||
|
it('uses +08:00 as the default MySQL session timezone to keep persisted timestamps in China time', () => {
|
||||||
|
const options = buildTypeOrmOptions(
|
||||||
|
createConfig({
|
||||||
|
DB_HOST: 'mysql',
|
||||||
|
DB_PORT: 3306,
|
||||||
|
DB_USERNAME: 'kt',
|
||||||
|
DB_PASSWORD: 'password',
|
||||||
|
DB_DATABASE: 'kt',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(options).toMatchObject({
|
||||||
|
timezone: '+08:00',
|
||||||
|
type: 'mysql',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows DB_TIMEZONE to override the default when a deployment needs a different MySQL session timezone', () => {
|
||||||
|
const options = buildTypeOrmOptions(
|
||||||
|
createConfig({
|
||||||
|
DB_TIMEZONE: 'Z',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(options.timezone).toBe('Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -172,7 +172,7 @@ describe('NapCat device identity persistence', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates a real-device style hostname without QQBot or container words', async () => {
|
it('generates a short QQNT-visible hostname without QQBot or container words', async () => {
|
||||||
const repository = createIdentityRepository();
|
const repository = createIdentityRepository();
|
||||||
const service = new NapcatDeviceIdentityService(
|
const service = new NapcatDeviceIdentityService(
|
||||||
repository as any,
|
repository as any,
|
||||||
@ -185,11 +185,11 @@ describe('NapCat device identity persistence', () => {
|
|||||||
selfId: '10001',
|
selfId: '10001',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(identity.hostname).toMatch(/^(ubuntu|linux)-pc-[a-f0-9]{8,12}$/);
|
expect(identity.hostname).toMatch(/^pc-[a-f0-9]{8}$/);
|
||||||
expect(identity.hostname).not.toMatch(/10001|qq|bot|napcat|docker/i);
|
expect(identity.hostname).not.toMatch(/10001|qq|bot|napcat|docker/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates a stable MAC from approved physical OUI prefixes', async () => {
|
it('generates a stable Docker bridge MAC that matches QQNT machine-info expectations', async () => {
|
||||||
const repository = createIdentityRepository();
|
const repository = createIdentityRepository();
|
||||||
const service = new NapcatDeviceIdentityService(
|
const service = new NapcatDeviceIdentityService(
|
||||||
repository as any,
|
repository as any,
|
||||||
@ -202,16 +202,15 @@ describe('NapCat device identity persistence', () => {
|
|||||||
selfId: '10001',
|
selfId: '10001',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(identity.macAddress).toMatch(/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/);
|
expect(identity.macAddress).toMatch(/^02:42:([0-9a-f]{2}:){3}[0-9a-f]{2}$/);
|
||||||
expect(identity.macAddress).not.toMatch(/^02:42/i);
|
|
||||||
expect(identity.macAddress).not.toMatch(/^52:54:00/i);
|
expect(identity.macAddress).not.toMatch(/^52:54:00/i);
|
||||||
expect(identity.macAddress).not.toMatch(
|
expect(identity.macAddress).not.toMatch(
|
||||||
/^(00:05:69|00:0c:29|00:1c:14|00:50:56)/i,
|
/^(00:05:69|00:0c:29|00:1c:14|00:50:56)/i,
|
||||||
);
|
);
|
||||||
expect(identity.macStrategy).toBe('physical-oui-v1');
|
expect(identity.macStrategy).toBe('docker-bridge-mac-v1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('records migration evidence when an existing Docker-style identity is upgraded', async () => {
|
it('records regression-repair evidence when an existing identity is realigned for QQNT', async () => {
|
||||||
const repository = createIdentityRepository();
|
const repository = createIdentityRepository();
|
||||||
repository.seedIdentity({
|
repository.seedIdentity({
|
||||||
accountId: 'account-10001',
|
accountId: 'account-10001',
|
||||||
@ -237,13 +236,14 @@ describe('NapCat device identity persistence', () => {
|
|||||||
selfId: '10001',
|
selfId: '10001',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(identity.macAddress).toMatch(/^02:42:/i);
|
||||||
expect(identity.macAddress).not.toBe('02:42:aa:bb:cc:dd');
|
expect(identity.macAddress).not.toBe('02:42:aa:bb:cc:dd');
|
||||||
expect(identity.hostname).not.toBe('kt-qqbot-napcat-10001');
|
expect(identity.hostname).not.toBe('kt-qqbot-napcat-10001');
|
||||||
expect(identity.lastLoginEvidence).toMatchObject({
|
expect(identity.lastLoginEvidence).toMatchObject({
|
||||||
migration: {
|
migration: {
|
||||||
fromMacAddress: '02:42:aa:bb:cc:dd',
|
fromMacAddress: '02:42:aa:bb:cc:dd',
|
||||||
strategy: 'physical-oui-v1',
|
strategy: 'docker-bridge-mac-v1',
|
||||||
trigger: 'legacy-docker-identity-upgrade',
|
trigger: 'qqnt-device-name-regression-repair',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -268,7 +268,9 @@ describe('NapCat device identity persistence', () => {
|
|||||||
deviceEnvPath: `${identity.dataDir}/device.env`,
|
deviceEnvPath: `${identity.dataDir}/device.env`,
|
||||||
hostname: identity.hostname,
|
hostname: identity.hostname,
|
||||||
machineIdPath: identity.machineIdPath,
|
machineIdPath: identity.machineIdPath,
|
||||||
|
machineInfoPath: `${identity.dataDir}/QQ/nt_qq/global/nt_data/msf/machine-info`,
|
||||||
macAddress: identity.macAddress,
|
macAddress: identity.macAddress,
|
||||||
|
macAddressHyphen: identity.macAddress.replace(/:/g, '-'),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(dockerOptions.runFlags).toEqual(
|
expect(dockerOptions.runFlags).toEqual(
|
||||||
@ -311,10 +313,20 @@ describe('NapCat device identity persistence', () => {
|
|||||||
expect(script).toContain(`NAPCAT_HOSTNAME='${identity.hostname}'`);
|
expect(script).toContain(`NAPCAT_HOSTNAME='${identity.hostname}'`);
|
||||||
expect(script).toContain(`NAPCAT_MAC_ADDRESS='${identity.macAddress}'`);
|
expect(script).toContain(`NAPCAT_MAC_ADDRESS='${identity.macAddress}'`);
|
||||||
expect(script).toContain(`MACHINE_ID_PATH='${identity.machineIdPath}'`);
|
expect(script).toContain(`MACHINE_ID_PATH='${identity.machineIdPath}'`);
|
||||||
|
expect(script).toContain(
|
||||||
|
`MACHINE_INFO_PATH='${identity.dataDir}/QQ/nt_qq/global/nt_data/msf/machine-info'`,
|
||||||
|
);
|
||||||
|
expect(script).toContain(
|
||||||
|
`NAPCAT_MAC_HYPHEN='${identity.macAddress.replace(/:/g, '-')}'`,
|
||||||
|
);
|
||||||
expect(script).toContain('cat > "$DEVICE_ENV_PATH" <<EOF');
|
expect(script).toContain('cat > "$DEVICE_ENV_PATH" <<EOF');
|
||||||
|
expect(script).toContain('MACHINE_INFO_PATH.bak.$(date +%Y%m%d%H%M%S)');
|
||||||
|
expect(script).toContain("printf '\\000\\000\\000\\021'");
|
||||||
|
expect(script).toContain("tr 'A-Za-z' 'N-ZA-Mn-za-m'");
|
||||||
expect(script).toContain('--hostname "$NAPCAT_HOSTNAME"');
|
expect(script).toContain('--hostname "$NAPCAT_HOSTNAME"');
|
||||||
expect(script).toContain('--mac-address "$NAPCAT_MAC_ADDRESS"');
|
expect(script).toContain('--mac-address "$NAPCAT_MAC_ADDRESS"');
|
||||||
expect(script).toContain('-v "$MACHINE_ID_PATH:/etc/machine-id:ro"');
|
expect(script).toContain('-v "$MACHINE_ID_PATH:/etc/machine-id:ro"');
|
||||||
|
expect(script).toContain('-v "$DATA_DIR/runtime:/tmp/runtime-napcat"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses persisted device identity when preparing an account managed container', async () => {
|
it('uses persisted device identity when preparing an account managed container', async () => {
|
||||||
@ -330,6 +342,7 @@ describe('NapCat device identity persistence', () => {
|
|||||||
where: jest.fn().mockReturnThis(),
|
where: jest.fn().mockReturnThis(),
|
||||||
})),
|
})),
|
||||||
findOne: jest.fn().mockResolvedValue(null),
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
update: jest.fn(),
|
||||||
};
|
};
|
||||||
const containerRepository = {
|
const containerRepository = {
|
||||||
create: jest.fn((input) => input),
|
create: jest.fn((input) => input),
|
||||||
@ -386,9 +399,19 @@ describe('NapCat device identity persistence', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const createScript = containerService.runProcess.mock.calls[0][2];
|
const createScript = containerService.runProcess.mock.calls[0][2];
|
||||||
|
expect(bindingRepository.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
accountId: 'account-10001',
|
||||||
|
containerId: 'container-created',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
deviceIdentityId: expect.any(String),
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(createScript).toContain('--hostname "$NAPCAT_HOSTNAME"');
|
expect(createScript).toContain('--hostname "$NAPCAT_HOSTNAME"');
|
||||||
expect(createScript).toContain('--mac-address "$NAPCAT_MAC_ADDRESS"');
|
expect(createScript).toContain('--mac-address "$NAPCAT_MAC_ADDRESS"');
|
||||||
expect(createScript).toContain('-v "$MACHINE_ID_PATH:/etc/machine-id:ro"');
|
expect(createScript).toContain('-v "$MACHINE_ID_PATH:/etc/machine-id:ro"');
|
||||||
|
expect(createScript).toContain('-v "$DATA_DIR/runtime:/tmp/runtime-napcat"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reuses persisted device identity when rebuilding an existing account container login env', async () => {
|
it('reuses persisted device identity when rebuilding an existing account container login env', async () => {
|
||||||
|
|||||||
@ -135,6 +135,94 @@ describe('NapCat runtime profile generation', () => {
|
|||||||
expect(profile.locale).not.toBe('C.UTF-8');
|
expect(profile.locale).not.toBe('C.UTF-8');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists planned runtime and protocol profiles when a managed container is rebuilt', async () => {
|
||||||
|
const runtimeProfileRepository = {
|
||||||
|
create: jest.fn((input) => ({ ...input })),
|
||||||
|
save: jest.fn(async (input) => input),
|
||||||
|
};
|
||||||
|
const protocolProfileRepository = {
|
||||||
|
create: jest.fn((input) => ({ ...input })),
|
||||||
|
save: jest.fn(async (input) => input),
|
||||||
|
};
|
||||||
|
const service = new (NapcatRuntimeProfileService as any)(
|
||||||
|
{
|
||||||
|
get: jest.fn((key: string, defaultValue?: string) => {
|
||||||
|
const values: Record<string, string> = {
|
||||||
|
QQBOT_NAPCAT_IMAGE: 'kt-napcat-desktop-cn@sha256:profiledigest',
|
||||||
|
QQBOT_NAPCAT_PROFILE_VERSION: 'napcat-runtime-profile-v1',
|
||||||
|
QQBOT_NAPCAT_PROTOCOL_PROFILE_VERSION: 'napcat-protocol-profile-v1',
|
||||||
|
};
|
||||||
|
return values[key] || defaultValue || '';
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
runtimeProfileRepository,
|
||||||
|
protocolProfileRepository,
|
||||||
|
);
|
||||||
|
const runtimeProfile = service.resolveRuntimeProfile({
|
||||||
|
accountId: 'account-1',
|
||||||
|
containerId: 'container-1',
|
||||||
|
dataDir: '/vol1/docker/kt-qqbot/napcat-instances/pc-a1b2c3d4',
|
||||||
|
deviceIdentityId: 'identity-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.recordPlannedProfiles({
|
||||||
|
accountId: 'account-1',
|
||||||
|
containerId: 'container-1',
|
||||||
|
dataDir: runtimeProfile.dataDir,
|
||||||
|
deviceIdentity: {
|
||||||
|
deviceIdentityId: 'identity-1',
|
||||||
|
hostname: 'pc-a1b2c3d4',
|
||||||
|
hostnameStrategy: 'qqnt-visible-hostname-v1',
|
||||||
|
machineInfoPath:
|
||||||
|
'/vol1/docker/kt-qqbot/napcat-instances/pc-a1b2c3d4/QQ/nt_qq/global/nt_data/msf/machine-info',
|
||||||
|
macAddress: '02:42:aa:bb:cc:dd',
|
||||||
|
macStrategy: 'docker-bridge-mac-v1',
|
||||||
|
},
|
||||||
|
protocolProfile: {
|
||||||
|
napcatConfigHash: 'napcat-hash',
|
||||||
|
napcatConfigJson: { o3HookMode: 1 },
|
||||||
|
o3HookGrayEnabled: false,
|
||||||
|
o3HookMode: 1,
|
||||||
|
onebotConfigHash: 'onebot-hash',
|
||||||
|
onebotConfigJson: { network: { websocketClients: [] } },
|
||||||
|
packetBackend: 'auto',
|
||||||
|
packetServer: '',
|
||||||
|
},
|
||||||
|
runtimeProfile,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runtimeProfileRepository.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
accountId: 'account-1',
|
||||||
|
containerId: 'container-1',
|
||||||
|
deviceIdentityId: 'identity-1',
|
||||||
|
hostnameStrategy: 'qqnt-visible-hostname-v1',
|
||||||
|
lastCheckEvidence: expect.objectContaining({
|
||||||
|
dataDir: runtimeProfile.dataDir,
|
||||||
|
machineInfoPath:
|
||||||
|
'/vol1/docker/kt-qqbot/napcat-instances/pc-a1b2c3d4/QQ/nt_qq/global/nt_data/msf/machine-info',
|
||||||
|
macAddress: '02:42:aa:bb:cc:dd',
|
||||||
|
}),
|
||||||
|
macStrategy: 'docker-bridge-mac-v1',
|
||||||
|
profileStatus: 'pending',
|
||||||
|
profileVersion: 'napcat-runtime-profile-v1',
|
||||||
|
timezoneEvidence: {
|
||||||
|
expectedTimezone: 'Asia/Shanghai',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(protocolProfileRepository.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
accountId: 'account-1',
|
||||||
|
containerId: 'container-1',
|
||||||
|
napcatConfigHash: 'napcat-hash',
|
||||||
|
onebotConfigHash: 'onebot-hash',
|
||||||
|
profileStatus: 'pending',
|
||||||
|
profileVersion: 'napcat-protocol-profile-v1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('writes account-level NapCat and OneBot configs with minimal reverse WS only', () => {
|
it('writes account-level NapCat and OneBot configs with minimal reverse WS only', () => {
|
||||||
const writer = new NapcatConfigWriterService(new ToolsService());
|
const writer = new NapcatConfigWriterService(new ToolsService());
|
||||||
const webuiAuthValue = 'KT_TEST_WEBUI_AUTH_VALUE';
|
const webuiAuthValue = 'KT_TEST_WEBUI_AUTH_VALUE';
|
||||||
|
|||||||
@ -36,9 +36,33 @@ describe('RuntimeConfigService', () => {
|
|||||||
database: 'kt',
|
database: 'kt',
|
||||||
username: 'admin',
|
username: 'admin',
|
||||||
synchronize: true,
|
synchronize: true,
|
||||||
|
timezone: '+08:00',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('surfaces DB_TIMEZONE in the safe runtime database profile and config checks', () => {
|
||||||
|
const service = createService({
|
||||||
|
DB_HOST: '127.0.0.1',
|
||||||
|
DB_PORT: '3307',
|
||||||
|
DB_DATABASE: 'kt',
|
||||||
|
DB_USERNAME: 'admin',
|
||||||
|
DB_TIMEZONE: 'Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.readDatabaseProfile()).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
timezone: 'Z',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(service.getConfigChecks()).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
key: 'DB_TIMEZONE',
|
||||||
|
level: 'optional',
|
||||||
|
present: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('masks secrets in checks and snapshots', () => {
|
it('masks secrets in checks and snapshots', () => {
|
||||||
const service = createService({
|
const service = createService({
|
||||||
ADMIN_TOKEN_SECRET: 'abcdef123456',
|
ADMIN_TOKEN_SECRET: 'abcdef123456',
|
||||||
|
|||||||
@ -17,6 +17,7 @@ function createSnapshot(
|
|||||||
database: 'kt',
|
database: 'kt',
|
||||||
username: 'root',
|
username: 'root',
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
|
timezone: '+08:00',
|
||||||
},
|
},
|
||||||
loki: {
|
loki: {
|
||||||
transportEnabled: true,
|
transportEnabled: true,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user