fix: 修复NapCat设备身份和时区配置
This commit is contained in:
parent
674d238f6f
commit
eaf22706d8
@ -4,6 +4,7 @@ DB_USERNAME=root
|
||||
DB_PASSWORD=
|
||||
DB_DATABASE=shy_template
|
||||
DB_SYNC=true
|
||||
DB_TIMEZONE=+08:00
|
||||
|
||||
MINIO_ENDPOINT=localhost
|
||||
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` 默认镜像。
|
||||
|
||||
托管 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
|
||||
|
||||
|
||||
@ -163,7 +163,7 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow
|
||||
- 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 容器为已知 `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 离线看门狗按 `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` 目录壳。
|
||||
|
||||
@ -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.
|
||||
@ -197,3 +197,10 @@ WHERE table_schema = DATABASE()
|
||||
AND table_name = 'napcat_device_identity'
|
||||
AND column_name = 'mac_strategy'
|
||||
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 { 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({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
@ -44,22 +67,8 @@ import { RuntimeModule } from './runtime';
|
||||
* 创建 模块依赖注入工厂产物。
|
||||
* @param configService - Nest ConfigService 依赖;使用 `get` 字段生成结果。
|
||||
*/
|
||||
useFactory: async (configService: ConfigService) => {
|
||||
return {
|
||||
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}'],
|
||||
};
|
||||
},
|
||||
useFactory: async (configService: ConfigService) =>
|
||||
buildTypeOrmOptions(configService),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
MinioModule.registerAsync({
|
||||
|
||||
@ -1,6 +1,35 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Optional } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
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()
|
||||
export class NapcatRuntimeProfileService {
|
||||
@ -8,7 +37,15 @@ export class NapcatRuntimeProfileService {
|
||||
* 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.
|
||||
*/
|
||||
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.
|
||||
@ -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.
|
||||
* @param key - Environment key that controls NapCat runtime profile generation.
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
import type { NapcatDeviceIdentity } from '../../persistence/napcat-device-identity.entity';
|
||||
|
||||
export type NapcatDockerDeviceOptions = {
|
||||
accountId: string;
|
||||
dataDir: string;
|
||||
deviceEnvPath: string;
|
||||
deviceIdentityId?: string;
|
||||
hostname: string;
|
||||
machineIdPath: string;
|
||||
machineInfoPath: string;
|
||||
macAddress: string;
|
||||
macAddressHyphen: string;
|
||||
hostnameStrategy?: string;
|
||||
macStrategy?: string;
|
||||
runFlags: string[];
|
||||
};
|
||||
|
||||
@ -18,16 +23,30 @@ export type NapcatDockerDeviceOptions = {
|
||||
export function toNapcatDockerDeviceOptions(
|
||||
identity: Pick<
|
||||
NapcatDeviceIdentity,
|
||||
'dataDir' | 'hostname' | 'id' | 'machineIdPath' | 'macAddress'
|
||||
| 'accountId'
|
||||
| 'dataDir'
|
||||
| 'hostname'
|
||||
| 'hostnameStrategy'
|
||||
| 'id'
|
||||
| 'machineIdPath'
|
||||
| 'macAddress'
|
||||
| 'macStrategy'
|
||||
>,
|
||||
): NapcatDockerDeviceOptions {
|
||||
const machineInfoPath = `${identity.dataDir}/QQ/nt_qq/global/nt_data/msf/machine-info`;
|
||||
|
||||
return {
|
||||
accountId: identity.accountId,
|
||||
dataDir: identity.dataDir,
|
||||
deviceEnvPath: `${identity.dataDir}/device.env`,
|
||||
deviceIdentityId: identity.id,
|
||||
hostname: identity.hostname,
|
||||
hostnameStrategy: identity.hostnameStrategy,
|
||||
machineIdPath: identity.machineIdPath,
|
||||
machineInfoPath,
|
||||
macAddress: identity.macAddress,
|
||||
macAddressHyphen: identity.macAddress.replace(/:/g, '-').toLowerCase(),
|
||||
macStrategy: identity.macStrategy,
|
||||
runFlags: [
|
||||
'--hostname "$NAPCAT_HOSTNAME"',
|
||||
'--mac-address "$NAPCAT_MAC_ADDRESS"',
|
||||
|
||||
@ -177,6 +177,8 @@ export class QqbotNapcatContainerService {
|
||||
);
|
||||
await this.createRemoteDockerContainer({
|
||||
account,
|
||||
accountId: deviceIdentity?.accountId,
|
||||
containerId: container.id,
|
||||
dataDir:
|
||||
deviceIdentity?.dataDir ||
|
||||
container.dataDir ||
|
||||
@ -246,6 +248,24 @@ export class QqbotNapcatContainerService {
|
||||
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 登录运行态流程。
|
||||
* @param container - container 输入;使用 `id` 字段生成结果。
|
||||
@ -611,6 +631,10 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
|
||||
{ accountId, isDeleted: false },
|
||||
{ isPrimary: false },
|
||||
);
|
||||
const deviceIdentityId = await this.resolveBindingDeviceIdentityId(
|
||||
accountId,
|
||||
containerId,
|
||||
);
|
||||
|
||||
const existing = await this.bindingRepository.findOne({
|
||||
where: {
|
||||
@ -627,6 +651,7 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
|
||||
isDeleted: false,
|
||||
lastLoginAt: new Date(),
|
||||
remark: '',
|
||||
...(deviceIdentityId ? { deviceIdentityId } : {}),
|
||||
},
|
||||
);
|
||||
await this.removeOtherAccountContainers(accountId, containerId);
|
||||
@ -638,6 +663,7 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
|
||||
accountId,
|
||||
bindStatus: 'bound',
|
||||
containerId,
|
||||
...(deviceIdentityId ? { deviceIdentityId } : {}),
|
||||
isPrimary: true,
|
||||
lastLoginAt: new Date(),
|
||||
remark: '',
|
||||
@ -1319,6 +1345,8 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
|
||||
try {
|
||||
await this.createRemoteDockerContainer({
|
||||
account: selfId,
|
||||
accountId,
|
||||
containerId: container.id,
|
||||
dataDir,
|
||||
deviceIdentity,
|
||||
image,
|
||||
@ -1337,11 +1365,15 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
|
||||
},
|
||||
);
|
||||
if (accountId && this.deviceIdentityService) {
|
||||
await this.deviceIdentityService.resolveForAccount({
|
||||
const identity = await this.deviceIdentityService.resolveForAccount({
|
||||
accountId,
|
||||
containerId: container.id,
|
||||
selfId,
|
||||
});
|
||||
await this.bindingRepository.update(
|
||||
{ accountId, containerId: container.id, isDeleted: false },
|
||||
{ deviceIdentityId: identity.id },
|
||||
);
|
||||
}
|
||||
return {
|
||||
baseUrl,
|
||||
@ -1370,6 +1402,8 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
|
||||
*/
|
||||
private async createRemoteDockerContainer(input: {
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
containerId?: string;
|
||||
dataDir: string;
|
||||
deviceIdentity?: NapcatDockerDeviceOptions;
|
||||
image: string;
|
||||
@ -1382,6 +1416,7 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
|
||||
}) {
|
||||
const script = this.buildRemoteCreateScript(input);
|
||||
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: {
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
containerId?: string;
|
||||
dataDir: string;
|
||||
deviceIdentity?: NapcatDockerDeviceOptions;
|
||||
@ -1433,20 +1469,36 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
|
||||
? [
|
||||
`NAPCAT_HOSTNAME=${this.sh(input.deviceIdentity.hostname)}`,
|
||||
`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_INFO_PATH=${this.sh(input.deviceIdentity.machineInfoPath)}`,
|
||||
`DEVICE_ENV_PATH=${this.sh(input.deviceIdentity.deviceEnvPath)}`,
|
||||
].join('\n') + '\n'
|
||||
: '';
|
||||
const devicePrepareScript = input.deviceIdentity
|
||||
? `
|
||||
mkdir -p "$(dirname "$DEVICE_ENV_PATH")" "$(dirname "$MACHINE_INFO_PATH")"
|
||||
cat > "$DEVICE_ENV_PATH" <<EOF
|
||||
NAPCAT_HOSTNAME=$NAPCAT_HOSTNAME
|
||||
NAPCAT_MAC_ADDRESS=$NAPCAT_MAC_ADDRESS
|
||||
NAPCAT_MAC_HYPHEN=$NAPCAT_MAC_HYPHEN
|
||||
MACHINE_ID_PATH=$MACHINE_ID_PATH
|
||||
MACHINE_INFO_PATH=$MACHINE_INFO_PATH
|
||||
EOF
|
||||
if [ ! -s "$MACHINE_ID_PATH" ]; then
|
||||
printf '%s' "$NAME" | sha256sum | cut -c 1-32 > "$MACHINE_ID_PATH"
|
||||
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
|
||||
@ -1467,8 +1519,9 @@ NAPCAT_SHM_SIZE=${this.sh(runtimeProfile.shmSize)}
|
||||
${accountHeader}
|
||||
${passwordHeader}
|
||||
${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/runtime"
|
||||
${devicePrepareScript}
|
||||
|
||||
${configWriteScript}
|
||||
@ -1497,11 +1550,74 @@ ${accountRunFlag}${passwordRunFlag}${deviceRunFlags} -p "$PORT:6099" \\
|
||||
-v "$DATA_DIR/plugins:/app/napcat/plugins" \\
|
||||
-v "$DATA_DIR/cache:/app/.cache" \\
|
||||
-v "$DATA_DIR/local-share:/app/.local/share" \\
|
||||
-v "$DATA_DIR/runtime:/tmp/runtime-napcat" \\
|
||||
-v "$DATA_DIR/logs:/app/napcat/logs" \\
|
||||
"$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.
|
||||
* @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 { Repository } from 'typeorm';
|
||||
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';
|
||||
|
||||
const QQNT_VISIBLE_HOSTNAME_STRATEGY = 'qqnt-visible-hostname-v1';
|
||||
const QQNT_DOCKER_BRIDGE_MAC_STRATEGY = 'docker-bridge-mac-v1';
|
||||
|
||||
type ResolveNapcatDeviceIdentityInput = {
|
||||
accountId: string;
|
||||
containerId?: string;
|
||||
@ -62,11 +61,13 @@ export class NapcatDeviceIdentityService {
|
||||
accountId,
|
||||
containerId: input.containerId || null,
|
||||
dataDir,
|
||||
hostname: this.buildDesktopHostname(`${accountId}:${input.selfId || ''}`),
|
||||
hostnameStrategy: 'desktop-hostname-v1',
|
||||
hostname: this.buildQqntVisibleHostname(
|
||||
`${accountId}:${input.selfId || ''}`,
|
||||
),
|
||||
hostnameStrategy: QQNT_VISIBLE_HOSTNAME_STRATEGY,
|
||||
lastLoginEvidence: null,
|
||||
macAddress: this.buildPhysicalMacAddress(accountId, containerName),
|
||||
macStrategy: 'physical-oui-v1',
|
||||
macAddress: this.buildDockerBridgeMacAddress(accountId, containerName),
|
||||
macStrategy: QQNT_DOCKER_BRIDGE_MAC_STRATEGY,
|
||||
machineIdPath: `${dataDir}/machine-id`,
|
||||
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.
|
||||
*/
|
||||
private buildDesktopHostname(seed: string) {
|
||||
private buildQqntVisibleHostname(seed: string) {
|
||||
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 containerName - Container name mixed into the deterministic seed.
|
||||
*/
|
||||
private buildPhysicalMacAddress(accountId: string, containerName: string) {
|
||||
private buildDockerBridgeMacAddress(
|
||||
accountId: string,
|
||||
containerName: string,
|
||||
) {
|
||||
const hash = createHash('sha256')
|
||||
.update(`${accountId}:${containerName}:physical-oui-v1`)
|
||||
.update(`${accountId}:${containerName}:${QQNT_DOCKER_BRIDGE_MAC_STRATEGY}`)
|
||||
.digest('hex');
|
||||
const prefixIndex =
|
||||
Number.parseInt(hash.slice(0, 4), 16) %
|
||||
NAPCAT_PHYSICAL_OUI_PREFIXES.length;
|
||||
const prefix = NAPCAT_PHYSICAL_OUI_PREFIXES[prefixIndex];
|
||||
const suffix = [hash.slice(4, 6), hash.slice(6, 8), hash.slice(8, 10)];
|
||||
const mac = `${prefix}:${suffix.join(':')}`.toLowerCase();
|
||||
|
||||
if (isRejectedVirtualMacPrefix(mac)) {
|
||||
throw new Error(`Rejected generated virtual MAC prefix: ${mac}`);
|
||||
}
|
||||
|
||||
return mac;
|
||||
const suffix = [
|
||||
hash.slice(0, 2),
|
||||
hash.slice(2, 4),
|
||||
hash.slice(4, 6),
|
||||
hash.slice(6, 8),
|
||||
];
|
||||
return `02:42:${suffix.join(':')}`.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -135,16 +134,20 @@ export class NapcatDeviceIdentityService {
|
||||
selfId: string;
|
||||
},
|
||||
) {
|
||||
const nextHostname = this.buildDesktopHostname(
|
||||
const nextHostname = this.buildQqntVisibleHostname(
|
||||
`${input.accountId}:${input.selfId}`,
|
||||
);
|
||||
const nextMacAddress = this.buildPhysicalMacAddress(
|
||||
const nextMacAddress = this.buildDockerBridgeMacAddress(
|
||||
input.accountId,
|
||||
input.containerName,
|
||||
);
|
||||
const needsMigration =
|
||||
identity.hostname !== nextHostname ||
|
||||
isRejectedVirtualMacPrefix(identity.macAddress);
|
||||
identity.hostnameStrategy !== QQNT_VISIBLE_HOSTNAME_STRATEGY ||
|
||||
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) {
|
||||
return;
|
||||
@ -154,10 +157,10 @@ export class NapcatDeviceIdentityService {
|
||||
migration: {
|
||||
fromHostname: identity.hostname,
|
||||
fromMacAddress: identity.macAddress,
|
||||
strategy: 'physical-oui-v1',
|
||||
strategy: QQNT_DOCKER_BRIDGE_MAC_STRATEGY,
|
||||
toHostname: nextHostname,
|
||||
toMacAddress: nextMacAddress,
|
||||
trigger: 'legacy-docker-identity-upgrade',
|
||||
trigger: 'qqnt-device-name-regression-repair',
|
||||
},
|
||||
};
|
||||
|
||||
@ -165,18 +168,18 @@ export class NapcatDeviceIdentityService {
|
||||
{ id: identity.id },
|
||||
{
|
||||
hostname: nextHostname,
|
||||
hostnameStrategy: 'desktop-hostname-v1',
|
||||
hostnameStrategy: QQNT_VISIBLE_HOSTNAME_STRATEGY,
|
||||
lastLoginEvidence: migrationEvidence,
|
||||
macAddress: nextMacAddress,
|
||||
macStrategy: 'physical-oui-v1',
|
||||
macStrategy: QQNT_DOCKER_BRIDGE_MAC_STRATEGY,
|
||||
},
|
||||
);
|
||||
Object.assign(identity, {
|
||||
hostname: nextHostname,
|
||||
hostnameStrategy: 'desktop-hostname-v1',
|
||||
hostnameStrategy: QQNT_VISIBLE_HOSTNAME_STRATEGY,
|
||||
lastLoginEvidence: migrationEvidence,
|
||||
macAddress: nextMacAddress,
|
||||
macStrategy: 'physical-oui-v1',
|
||||
macStrategy: QQNT_DOCKER_BRIDGE_MAC_STRATEGY,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -23,6 +23,7 @@ const REQUIRED_CONFIG_KEYS = [
|
||||
] as const;
|
||||
|
||||
const OPTIONAL_CONFIG_CHECKS: ReadonlyArray<string | readonly string[]> = [
|
||||
'DB_TIMEZONE',
|
||||
'MINIO_ENDPOINT',
|
||||
'MINIO_PORT',
|
||||
'MINIO_ACCESS_KEY',
|
||||
@ -95,6 +96,7 @@ export class RuntimeConfigService {
|
||||
database: this.getString('DB_DATABASE'),
|
||||
username: this.getString('DB_USERNAME'),
|
||||
synchronize: this.getBoolean('DB_SYNC', false),
|
||||
timezone: this.getString('DB_TIMEZONE', '+08:00'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ export interface RuntimeDatabaseConfig {
|
||||
database: string;
|
||||
username: string;
|
||||
synchronize: boolean;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
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 service = new NapcatDeviceIdentityService(
|
||||
repository as any,
|
||||
@ -185,11 +185,11 @@ describe('NapCat device identity persistence', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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 service = new NapcatDeviceIdentityService(
|
||||
repository as any,
|
||||
@ -202,16 +202,15 @@ describe('NapCat device identity persistence', () => {
|
||||
selfId: '10001',
|
||||
});
|
||||
|
||||
expect(identity.macAddress).toMatch(/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/);
|
||||
expect(identity.macAddress).not.toMatch(/^02:42/i);
|
||||
expect(identity.macAddress).toMatch(/^02:42:([0-9a-f]{2}:){3}[0-9a-f]{2}$/);
|
||||
expect(identity.macAddress).not.toMatch(/^52:54:00/i);
|
||||
expect(identity.macAddress).not.toMatch(
|
||||
/^(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();
|
||||
repository.seedIdentity({
|
||||
accountId: 'account-10001',
|
||||
@ -237,13 +236,14 @@ describe('NapCat device identity persistence', () => {
|
||||
selfId: '10001',
|
||||
});
|
||||
|
||||
expect(identity.macAddress).toMatch(/^02:42:/i);
|
||||
expect(identity.macAddress).not.toBe('02:42:aa:bb:cc:dd');
|
||||
expect(identity.hostname).not.toBe('kt-qqbot-napcat-10001');
|
||||
expect(identity.lastLoginEvidence).toMatchObject({
|
||||
migration: {
|
||||
fromMacAddress: '02:42:aa:bb:cc:dd',
|
||||
strategy: 'physical-oui-v1',
|
||||
trigger: 'legacy-docker-identity-upgrade',
|
||||
strategy: 'docker-bridge-mac-v1',
|
||||
trigger: 'qqnt-device-name-regression-repair',
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -268,7 +268,9 @@ describe('NapCat device identity persistence', () => {
|
||||
deviceEnvPath: `${identity.dataDir}/device.env`,
|
||||
hostname: identity.hostname,
|
||||
machineIdPath: identity.machineIdPath,
|
||||
machineInfoPath: `${identity.dataDir}/QQ/nt_qq/global/nt_data/msf/machine-info`,
|
||||
macAddress: identity.macAddress,
|
||||
macAddressHyphen: identity.macAddress.replace(/:/g, '-'),
|
||||
}),
|
||||
);
|
||||
expect(dockerOptions.runFlags).toEqual(
|
||||
@ -311,10 +313,20 @@ describe('NapCat device identity persistence', () => {
|
||||
expect(script).toContain(`NAPCAT_HOSTNAME='${identity.hostname}'`);
|
||||
expect(script).toContain(`NAPCAT_MAC_ADDRESS='${identity.macAddress}'`);
|
||||
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('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('--mac-address "$NAPCAT_MAC_ADDRESS"');
|
||||
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 () => {
|
||||
@ -330,6 +342,7 @@ describe('NapCat device identity persistence', () => {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
})),
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const containerRepository = {
|
||||
create: jest.fn((input) => input),
|
||||
@ -386,9 +399,19 @@ describe('NapCat device identity persistence', () => {
|
||||
}),
|
||||
);
|
||||
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('--mac-address "$NAPCAT_MAC_ADDRESS"');
|
||||
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 () => {
|
||||
|
||||
@ -135,6 +135,94 @@ describe('NapCat runtime profile generation', () => {
|
||||
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', () => {
|
||||
const writer = new NapcatConfigWriterService(new ToolsService());
|
||||
const webuiAuthValue = 'KT_TEST_WEBUI_AUTH_VALUE';
|
||||
|
||||
@ -36,9 +36,33 @@ describe('RuntimeConfigService', () => {
|
||||
database: 'kt',
|
||||
username: 'admin',
|
||||
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', () => {
|
||||
const service = createService({
|
||||
ADMIN_TOKEN_SECRET: 'abcdef123456',
|
||||
|
||||
@ -17,6 +17,7 @@ function createSnapshot(
|
||||
database: 'kt',
|
||||
username: 'root',
|
||||
synchronize: false,
|
||||
timezone: '+08:00',
|
||||
},
|
||||
loki: {
|
||||
transportEnabled: true,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user