Compare commits

...

13 Commits

414 changed files with 12570 additions and 1376 deletions

80
API.md
View File

@ -52,20 +52,20 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
## Runtime Health
| 方法 | 路径 | 认证 | 说明 |
| ----- | ----------------- | ---- | ---------------------------------- |
| `GET` | `/health/runtime` | 否 | API 运行时健康和配置检查状态 |
| 方法 | 路径 | 认证 | 说明 |
| ----- | ----------------- | ---- | ---------------------------- |
| `GET` | `/health/runtime` | 否 | API 运行时健康和配置检查状态 |
该接口返回 plain JSON不使用 Vben 响应包装,供本地 smoke、Jenkins/K8s 和 ktWorkflow 观测脚本直接读取。接口位于 Swagger 基础能力分组 `/api/basic`
顶层字段:
| 字段 | 说明 |
| ----------- | ------------------------------------------------ |
| `service` | 固定为 `kt-template-online-api` |
| `checkedAt` | ISO 时间字符串 |
| `status` | `live`、`ready`、`degraded` 或 `blocked` |
| `checks` | 进程和配置检查列表 |
| 字段 | 说明 |
| ----------- | ---------------------------------------- |
| `service` | 固定为 `kt-template-online-api` |
| `checkedAt` | ISO 时间字符串 |
| `status` | `live`、`ready`、`degraded` 或 `blocked` |
| `checks` | 进程和配置检查列表 |
公开响应不返回数据库、WordPress、Loki、NapCat SSH 等运行拓扑配置快照;配置检查只暴露 key 级别、是否存在和缺失说明。
@ -78,19 +78,19 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
## 环境变量分组
| 分组 | 关键变量 |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET` |
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD` |
| Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_QUERY_SELECTOR` |
| 分组 | 关键变量 |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET` |
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD` |
| Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_QUERY_SELECTOR` |
| QQBot | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_PATH`、`QQBOT_REVERSE_WS_TOKEN`、`QQBOT_EVENT_BUS`、`QQBOT_SEND_*`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*` |
| NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` |
| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` |
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_DEFAULT_WORLD` |
| FFLogs | `FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
| NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` |
| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` |
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_DEFAULT_WORLD` |
| FFLogs | `FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
真实密码、Token、OAuth secret 和生产 env 不提交到 Git。
@ -345,8 +345,12 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。已有账号的更新登录会先重启目标 NapCat 容器尝试 `ACCOUNT`/`-q` 快速登录;目标账号在线则直接完成会话。没有历史登录态的新容器会跳过快速登录,优先尝试保存的登录密码;快速登录失败后,如果账号保存了登录密码,会临时注入 `NAPCAT_QUICK_PASSWORD` 并按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询密码登录结果,准备阶段的扫码会话会持续续期,避免后台密码登录未完成时前端先判过期。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。会话已有 `captchaUrl` 后,`/qqbot/account/scan/status` 遇到 NapCat 继续返回“需要验证码/继续完成验证/安全验证”但不带 URL 时仍保持 `pending` 和原 `captchaUrl`。密码登录成功后会重建容器移除该运行态密码,清理失败则本次登录失败;密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,再进入重置登录态和二维码兜底流程。看门狗自动登录使用同样的 quick -> password 顺序,但不会自动进入扫码阶段。
密码验证码通过后如果 NapCat 返回 `needNewDevice`,后端不会只把 `jumpUrl` 透给 Admin而是在同一会话中继续调用 `/api/QQLogin/GetNewDeviceQRCode` 生成新设备验证二维码;`/qqbot/account/scan/status` 后续轮询会代理 `/api/QQLogin/PollNewDeviceQR`,状态映射为 `newDeviceStatus=qr-pending|scanned|confirming|verified|expired|failed`,进入确认态后再调用 `/api/QQLogin/NewDeviceLogin` 并回到密码登录完成检查。扫码会话结果新增 `newDeviceQrcode`、`newDeviceStatus`、`deviceVerifyUrl` 字段;`captchaUrl` 和 `newDeviceQrcode` 分别表示腾讯安全验证码和 QQ 新设备验证二维码前端必须分开展示。SSE 进度文案包含快速登录、密码登录、验证码、新设备二维码、已扫码、确认中、登录成功/失败和运行态清理失败。
同一 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 判定为新设备的概率。
外发消息不直接抢发:后端会按 `QQBOT_SEND_GLOBAL_INTERVAL_MS`、`QQBOT_SEND_TARGET_INTERVAL_MS` 和 `QQBOT_SEND_JITTER_MS` 预约发送窗口,默认全局 2500ms、同会话 8000ms、抖动 0-800ms如果等待超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS`,本次发送会在下发前被拒绝。在线命令和自动回复规则会叠加运行时保底冷却,默认命令 5000ms、规则 30000ms复读机默认连续 4 次相同普通文本才触发,同一会话默认 10 分钟内只复读一次,并限制普通文本长度,减少自动行为被风控识别的概率。
### Command / Rule / Permission
@ -407,6 +411,36 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
| `GET` | `/qqbot/conversation/list` | 会话列表 |
| `GET` | `/qqbot/message/list` | 消息列表 |
### Plugin Platform
插件平台使用统一 `plugin.json` manifest 描述插件 key、版本、入口、操作、事件、权限和运行预算后端会校验路径必须留在插件包内权限必须命中白名单安装包 content hash 必须与 manifest 匹配。CLI 入口:
```bash
pnpm qqbot-plugin create <pluginKey>
pnpm qqbot-plugin validate <pluginDir>
pnpm qqbot-plugin pack <pluginDir>
pnpm qqbot-plugin install-local <packageFile>
```
平台管理接口:
| 方法 | 路径 | 说明 |
| ------ | ----------------------------------------- | ---------------------------------- |
| `GET` | `/qqbot/plugin-platform/installations` | 插件安装记录,支持 key/status 过滤 |
| `POST` | `/qqbot/plugin-platform/upload` | 上传插件包并返回校验摘要 |
| `POST` | `/qqbot/plugin-platform/validate` | 校验 manifest JSON |
| `POST` | `/qqbot/plugin-platform/install` | 按上传包安装插件版本 |
| `POST` | `/qqbot/plugin-platform/install-local` | 按本地包路径安装插件版本 |
| `POST` | `/qqbot/plugin-platform/enable` | 启用插件安装 |
| `POST` | `/qqbot/plugin-platform/disable` | 禁用插件安装 |
| `POST` | `/qqbot/plugin-platform/upgrade` | 升级插件安装版本 |
| `POST` | `/qqbot/plugin-platform/uninstall` | 卸载插件安装 |
| `POST` | `/qqbot/plugin-platform/config` | 保存插件配置 |
| `GET` | `/qqbot/plugin-platform/runtime-events` | 查询插件运行事件 |
| `GET` | `/qqbot/plugin-platform/account-bindings` | 查询插件账号绑定 |
`src/modules/qqbot/plugin-platform/runtime` 当前提供 host-side driver 边界和超时/崩溃事件归档;实际 worker/child-process driver 可以在后续批次接入,但插件侧只能通过受控 SDK 访问发送队列、配置、存储、HTTP、资产和事件上下文。
### OneBot Reverse WebSocket
`QQBOT_REVERSE_WS_PATH` 默认是 `/qqbot/onebot/reverse`。NapCat 通过反向 WS 连接 APItoken 使用 `QQBOT_REVERSE_WS_TOKEN`
@ -415,7 +449,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
### BangDream
插件 key`bangDream`。当前源码根目录为 `src/qqbot/plugins/bangDream`,不再使用旧 `tsugu` 子目录。
插件 key`bangdream`。旧 `bangDream` 作为兼容别名仍可解析;当前源码根目录为 `src/modules/qqbot/plugins/bangDream`,不再使用旧 `tsugu` 子目录。
| operation key | 命令 | 说明 |
| ----------------------------- | ------------- | ------------------------ |
@ -439,7 +473,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
### FF14 Market
插件 key`ff14Market`。
插件 key`ff14-market`。旧 `ff14Market` 作为兼容别名仍可解析
| operation key | 说明 |
| ------------------- | ------------------------------------------ |

View File

@ -16,17 +16,18 @@
## 功能模块
| 模块 | 说明 |
| -------------------------- | -------------------------------------------------------------------------------- |
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志 |
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
| `qqbot` | QQBot 账号、NapCat 扫码登录、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志 |
| `qqbot/plugins/bangDream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
| `qqbot/plugins/ff14Market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 |
| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除 |
| `common` | 响应封装、异常过滤、请求日志、日期格式化、字典解码、Snowflake、工具服务 |
| 模块 | 说明 |
| ------------------------------- | ------------------------------------------------------------------------------------------ |
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志 |
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
| `qqbot` | QQBot 账号、NapCat 扫码登录、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 |
| `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、受控 SDK 和 CLI 脚手架 |
| `qqbot/plugins/bangDream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
| `qqbot/plugins/ff14Market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 |
| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除 |
| `common` | 响应封装、异常过滤、请求日志、日期格式化、字典解码、Snowflake、工具服务 |
## 目录结构
@ -36,6 +37,7 @@ src/
blog/ 本地博客内容与主题配置
common/ 全局装饰器、过滤器、拦截器、logger、工具和类型
minio/ MinIO 文件服务
modules/ 第三期重构后的业务边界模块
qqbot/ QQBot 运行态、管理接口和插件生态
wordpress/ WordPress REST 代理
app.module.ts
@ -53,17 +55,17 @@ ci/ Jenkins Agent/Docker 辅助文件
主要配置分组:
| 分组 | 变量 |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET` |
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD`、`WORDPRESS_*_TIMEOUT_MS` |
| Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` |
| 分组 | 变量 |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET` |
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD`、`WORDPRESS_*_TIMEOUT_MS` |
| Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` |
| QQBot/NapCat | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_*`、`QQBOT_SEND_*`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*`、`NAPCAT_*`、`QQBOT_NAPCAT_*`、`MQTT_*` |
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_MARKET_CACHE_TTL_MS` |
| FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_MARKET_CACHE_TTL_MS` |
| FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
`DB_SYNC=true` 只适合本地开发或明确允许自动同步表结构的环境;生产应关闭并使用 SQL/迁移脚本。
@ -85,6 +87,10 @@ pnpm run typecheck
pnpm run lint
pnpm test
pnpm run build
pnpm qqbot-plugin create <pluginKey>
pnpm qqbot-plugin validate <pluginDir>
pnpm qqbot-plugin pack <pluginDir>
pnpm qqbot-plugin install-local <packageFile>
```
Jest 只扫描 `test/**/*.spec.ts`。如果在 Windows 下指定测试文件,使用:
@ -146,12 +152,15 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow
- QQBot 外发统一走发送排队:默认全局间隔 `2500ms`、同会话间隔 `8000ms`、排队抖动 `0-800ms`,超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS` 时拒绝本次发送,避免高频自动回复形成突发流量。
- QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。
- QQBot 复读机默认阈值为 4同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。
- QQBot 插件平台统一使用 `plugin.json` manifest 描述插件 key、版本、操作、事件、权限、运行预算和包入口CLI 负责 create/validate/pack/install-local后端只暴露受控 SDK 能力并通过插件维度记录安装、配置、账号绑定和运行事件。
- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表拆开展示 OneBot、容器、WebUI 和 QQ 登录态,心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态;`qqLoginMessage` 只展示 QQ 登录态消息WebUI 配置或请求错误留在 `lastError`
- 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` 轮询结果,准备中的扫码会话会续期,避免后台密码登录未结束时前端先过期;如果 NapCat 返回 `proofWaterUrl` 或日志出现“需要验证码”,会保持会话 pending 并把腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`,不是让用户只打开外链;密码登录成功后会移除运行态 `NAPCAT_QUICK_PASSWORD`,清理失败则本次登录失败,避免成功态残留明文 env密码登录也失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时才重置登录态并生成二维码。Admin「更新登录」的 SSE 步骤顺序按实际路径为 `quick-login-*`(已有历史会话)-> `password-login-*` / `password-login-captcha` -> `password-env-cleanup` -> `relogin-reset/qrcode/waiting-scan`
- NapCat 设备身份按账号持久化到 `napcat_device_identity`同一账号重建容器会复用数据目录、hostname、machine-id 和 MAC并在 Docker run 中注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id`,避免频繁重建被 QQ 判定为全新设备。
- 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/qqbot/plugins/bangDream`;不要恢复旧 `tsugu` 层级或旧大桶目录。
- BangDream 当前源码根目录是 `src/modules/qqbot/plugins/bangDream`;不要恢复旧 `tsugu` 层级或旧大桶目录。
- BangDream 在线命令以 `registry/operation-registry.ts` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 registry/command-SQL 测试。
- BangDream event stage 大图必须保持分页拆图行为,线上 smoke 关注 `imageCount=5`,避免大 canvas OOM 回归。
@ -187,4 +196,4 @@ BangDream 图片能力改动:
| 一级来源 | 使用方式 | License |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------- |
| [Tsugu BangDream Bot](https://github.com/Yamamoto-2/tsugu-bangdream-bot) | BangDream QQBot 后端能力已重构合入 `src/qqbot/plugins/bangDream`,保留本地 `TSUGU-LICENSE` | MIT |
| [Tsugu BangDream Bot](https://github.com/Yamamoto-2/tsugu-bangdream-bot) | BangDream QQBot 后端能力已重构合入 `src/modules/qqbot/plugins/bangDream`,保留本地 `TSUGU-LICENSE` | MIT |

View File

@ -0,0 +1,12 @@
# API/Admin Contract Matrix
| Batch | API Contract | Admin Surface | Smoke Evidence |
| --- | --- | --- | --- |
| 1 | `GET /health/runtime` plain JSON, runtime adapter internals | Runtime status remains available | `curl http://localhost:<port>/health/runtime` |
| 2 | `/auth/*`, `/admin/user/*`, `/admin/menu/*`, `/admin/role/*`, `/admin/dept/*`, `/admin/dict/*`, `/admin/notice/*` | Login, menu, system pages | Login request, menu load, route render |
| 3 | `/blog/*`, `/wordpress/*`, `/asset/*` | Blog, WordPress, Asset pages | Public blog request, Admin list request, asset upload smoke |
| 4 | `/qqbot/account/*`, `/qqbot/command/*`, `/qqbot/rule/*`, `/qqbot/message/*`, `/qqbot/send/*` | QQBot core pages | `/qqbot/command/test` local request |
| 5 | `/qqbot/plugin-platform/*` | Plugin upload/install/enable/config/health pages | local test plugin install and enable |
| 6 | plugin operations exposed through QQBot command/event routing | Existing plugin pages and operation views | BangDream, FF14, FFLogs, Repeater smoke |
| 7 | `/qqbot/napcat/*`, login SSE events | NapCat device/login progress pages | simulated captcha and new-device session |
| 8 | public deployed URLs | deployed Admin | online smoke bundle |

View File

@ -0,0 +1,20 @@
# Refactor V3 Breaking Changes
## Approved
| Area | Change | Reason | First Batch |
| --- | --- | --- | --- |
| Database | Rebuild all API-owned tables from new full schema | Current data is not important and old schema blocks clean module ownership | Batch 0 |
| SQL init | Replace historical patch scripts with `sql/refactor-v3/*` | Avoid accumulated `ALTER TABLE` history | Batch 0 |
| Admin QQBot API types | Replace old QQBot caller types with new contract | New QQBot Core, Plugin Platform, and NapCat state model | Batch 4 |
## Protected Behavior
- Admin login succeeds.
- Admin menu loads.
- Vben success/error wrappers remain stable for Admin APIs.
- `/health/runtime` remains plain JSON.
- Blog public list/detail remain available.
- QQBot command test remains available.
- QQBot status keeps OneBot, container, WebUI, and QQ login state separate.
- NapCat cleanup failure blocks success.

View File

@ -0,0 +1,31 @@
# Refactor V3 Rebuild Runbook
## Local Dry Run
1. Create an empty local database dedicated to refactor V3.
2. Apply `sql/refactor-v3/00-full-schema.sql`.
3. Apply `sql/refactor-v3/01-seed-core.sql`.
4. Run `sql/refactor-v3/99-verify.sql`.
5. Start API against the dry-run database.
6. Run `scripts/refactor-v3/local-smoke.ps1`.
## Online Backup
1. Confirm the exact API image tag and current database name.
2. Run `scripts/refactor-v3/db-backup-online.ps1`.
3. Record backup path, timestamp, source database, and restore command.
## Online Rebuild
1. Stop or limit API write traffic.
2. Apply full schema and seed scripts.
3. Run verify SQL.
4. Deploy API/Admin image versions bound to this schema.
5. Run online smoke bundle.
## Rollback
1. Stop write traffic.
2. Restore the recorded backup or previous schema bundle.
3. Roll back API/Admin images.
4. Re-run smoke for the restored version.

View File

@ -0,0 +1,25 @@
# Refactor V3 Schema Map
## Global Rules
- Primary keys are Snowflake `BIGINT`; API/Admin boundary treats IDs as strings.
- Table names use lower snake case.
- Queryable values are structured columns, not JSON-only fields.
- Event/log tables are append-only and have retention strategy.
- New schema is full initialization SQL, not historical `ALTER TABLE` patches.
## Domains
| Domain | Tables | Owner Batch | Notes |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Admin Identity | `admin_user`, `admin_role`, `admin_permission`, `admin_menu`, `admin_dept`, `admin_department`, `admin_user_role`, `admin_role_permission`, `admin_role_menu` | Batch 2 | Runtime login/menu/role/dept tables plus future permission and department boundary tables. |
| Platform Config | `admin_dict`, `admin_component`, `platform_dict_group`, `platform_dict_item`, `platform_component_template`, `platform_setting` | Batch 2 | Runtime Admin dict/component compatibility plus new platform dict/component template split. |
| Blog Content | `blog_post`, `blog_article`, `blog_taxonomy`, `blog_term`, `blog_post_term`, `blog_theme_profile`, `blog_theme_config`, `blog_import_job` | Batch 3 | `blog_article`/`blog_theme_config` keep current runtime compatibility; `blog_term` carries both runtime category/tag fields and nullable taxonomy planning fields. |
| WordPress Mirror | `wordpress_site`, `wordpress_auth_session`, `wordpress_remote_post`, `wordpress_remote_term`, `wordpress_sync_job`, `wordpress_sync_mapping` | Batch 3 | Remote state separate from local Blog content. |
| Asset | `asset_bucket`, `asset_object`, `asset_reference`, `asset_access_grant` | Batch 3 | MinIO object ownership and access grant. |
| System Event | `admin_notice`, `system_notice`, `system_event`, `system_event_dedupe`, `system_event_delivery` | Batch 2 | Admin notices and MySQL actionable events; Loki remains log query source. |
| Runtime Evidence | `runtime_evidence_index` | Batch 1 | Safe index only, no large logs or secrets. |
| QQBot Core | `qqbot_account`, `qqbot_account_ability`, `qqbot_connection_session`, `qqbot_capability_binding`, `qqbot_permission_policy`, `qqbot_allowlist`, `qqbot_blocklist`, `qqbot_command`, `qqbot_command_alias`, `qqbot_command_log`, `qqbot_config`, `qqbot_rule`, `qqbot_conversation`, `qqbot_message`, `qqbot_send_task`, `qqbot_send_log`, `qqbot_dedupe_event`, `qqbot_dedupe`, `qqbot_account_napcat`, `qqbot_napcat_container` | Batch 4 | Account, connection, permission, command, message, send queue, and transitional registered-entity compatibility tables until Batches 5-7 replace plugin/NapCat internals. |
| NapCat Runtime | `napcat_container`, `napcat_device_identity`, `napcat_account_binding`, `napcat_login_session`, `napcat_login_challenge`, `napcat_runtime_cleanup` | Batch 7 | Device identity and login challenge state. |
| QQBot Plugin Platform | `qqbot_plugin`, `qqbot_plugin_version`, `qqbot_plugin_installation`, `qqbot_plugin_operation`, `qqbot_plugin_event_handler`, `qqbot_plugin_account_binding`, `qqbot_plugin_config`, `qqbot_plugin_asset`, `qqbot_plugin_runtime_event` | Batch 5 | Manifest, install lifecycle, runtime health, bindings. |
| Plugin-Owned Data | plugin namespace tables | Batch 6 | Table names start with registered plugin namespace. |

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,405 @@
# API 全模块架构、全库表设计与 QQBot 插件平台设计
## 背景
`kt-template-online-api` 已经从一个相对紧凑的 NestJS 后端,演进为承接
Admin、Blog、WordPress、MinIO、QQBot、NapCat、Loki 运行期事件和线上部署
观测的多领域服务。第一期 Runtime Foundation 已经补上 `/health/runtime`
运行时配置检查和证据格式,为后续重构提供了基础观测能力。
第二期不是继续只修 QQBot 或 NapCat而是进入 API 全模块架构规划。当前线上
数据不重要,因此后续实现阶段允许从头设计数据库表,允许重命名表、删除旧字段、
拆分或合并旧表,并重建初始化 SQL。即便允许破坏式重建实现阶段仍必须保留
备份、回滚和线上 smoke避免线上动作不可追踪。
## 范围决策
本阶段是用户确认的 B 阶段:先完成全局规划,不写业务实现代码。
规划范围:
1. 定义 API 目标模块架构。
2. 从头设计全库 schema。
3. 设计 QQBot 插件平台,支持 worker/child process 隔离、线上安装、热插拔和
CLI 脚手架。
4. 定义后续 C 阶段的迁移矩阵和实施顺序。
本文件不实现代码、SQL、Admin 页面或运行时行为。用户 review 通过后,下一步
进入 Superpowers `writing-plans`。完整 C 阶段迁移开始前,必须重新走一轮
Superpowers brainstorming。
## 目标
- 明确每个 controller、use case、entity、repository、外部集成和测试的模块归属。
- 用干净的新 schema 替代当前补丁式表历史。
- 默认保持外部 API 兼容,确实需要破坏兼容时提前列入 breaking changes。
- 将 QQBot 插件从硬编码 Nest provider 集合升级为统一插件平台。
- 通过 CLI 脚手架让插件创建、校验、打包和本地安装可重复。
- 支持线上插件安装、校验、启用、禁用、升级和卸载,不重启整个 API 进程。
- 将现有 BangDream、FF14 Market、FFLogs、Repeater 按统一插件契约重写。
- 继承第一期 Runtime Foundation 的可靠性和证据模型,作为后续模块迁移的验证底座。
## 非目标
- 本规划阶段不写实现代码。
- 不为了旧数据迁移方便而保留旧表结构。
- 不把 Jenkins/K8s rollout 成功当作功能成功。
- 本阶段不改 Admin 前端。Admin 联动只在本文规划,后续实现前重新 brainstorming。
- 不绕过 QQ 或腾讯安全校验。NapCat 验证码和新设备验证仍然由用户驱动。
- 不允许线上安装的插件代码直接访问后端真实密钥、TypeORM Repository 或任意 Nest DI。
## 目标架构
目标源码结构按平台基础、通用能力和业务模块拆分:
```text
src/
runtime/
common/
modules/
admin/
blog/
wordpress/
asset/
qqbot/
```
`runtime` 负责运行时基础能力:
- 类型化运行时配置 profile。
- 带超时、安全摘要和错误分类的 HTTP/process client。
- 健康检查和 `/health/runtime`
- 运行证据记录。
- 清理语义。
`common` 只保留稳定通用能力:
- Vben 响应和错误工具。
- 全局 filter/interceptor。
- 时间字段装饰器和序列化工具。
- Snowflake ID。
- 低层文本、对象工具。
- 非业务专属的日志基础能力。
业务模块内部统一结构:
```text
module/
contract/
application/
domain/
infrastructure/
persistence/
integration/
schema/
tests/
```
### 分层职责
`contract`Controller、请求 DTO、响应 DTO、Swagger 元信息和现有路由兼容适配。
`application`:用例编排、事务边界、权限检查和状态流转。可以依赖 domain、
repository 和 integration port但不能直接拼外部 HTTP、Docker 脚本或临时 SQL。
`domain`纯业务规则、状态机、策略和值对象。Domain 代码应能脱离 Nest、TypeORM、
Docker、HTTP 和外部凭据做单元测试。
`infrastructure/persistence`TypeORM Entity、Repository 实现、查询模型和 schema mapper。
`infrastructure/integration`WordPress HTTP、MinIO SDK、Loki query、OneBot WebSocket、
NapCat WebUI、插件 worker RPC 和 runtime process 适配器。
`schema`:模块表设计说明、初始化 SQL 归属、重建脚本和验证 SQL。
`tests`:模块级 unit、contract、repository 和 integration smoke。跨模块用户流程测试可以
继续放在根 `test/` 下。
### 兼容规则
- 外部 route path 默认保持稳定,除非实现计划明确列入 breaking changes。
- Admin-facing 业务接口继续使用当前 Vben success/error wrapperstream、file、WebSocket
和 plain JSON 接口除外。
- `/health/runtime` 继续返回 plain JSON。
- Snowflake ID 在 MySQL 中继续使用 `BIGINT`,在 JavaScript/API 边界按字符串语义处理。
- 时间字段继续使用 `KtDateTime extends Date` 和 KT 时间装饰器。
- 后续实现阶段可以替换旧 SQL 文件为全量新库初始化文件,但本规划阶段不改 SQL 实现。
## 全库表设计
后续实现阶段允许从头重建 schema。由于当前数据不重要目标 schema 优先服务清晰
数据域和模块所有权,而不是旧表兼容。实现阶段仍必须在破坏式数据库动作前备份,
并提供回滚或恢复命令。
### 全局表约定
- 主键使用 Snowflake `BIGINT`
- 外键列命名为 `*_id` 并建立索引。
- 稳定关系表使用组合唯一约束。
- 高频日志和事件表避免硬数据库外键,减少 retention 和清理成本。
- 只有可恢复、可隐藏的业务配置使用软删。
- 运行事件、命令日志、消息日志、插件运行事件使用 append-only 加 retention。
- 通用时间字段使用 `create_time`、`update_time`,需要软删时加 `delete_time`
- 状态字段使用明确的 varchar 枚举,并在 schema 说明中列出允许值。
- JSON 字段只承载外部原始 payload、插件 metadata、低频配置或证据详情可查询字段必须结构化。
- 有 UI 或存储长度限制的文本字段必须写明截断规则。
- 新初始化 SQL 应是干净的全量 schema不再累积历史 `ALTER TABLE` 补丁。
### 数据域表规划
| 数据域 | 目标表 | 说明 |
| --- | --- | --- |
| Admin Identity | `admin_user`、`admin_role`、`admin_permission`、`admin_menu`、`admin_department`、`admin_user_role`、`admin_role_permission`、`admin_role_menu` | 保持 Admin 登录和菜单行为稳定,同时拆清路由菜单与权限原子。部门为树结构。头像、时区和首页路径属于用户 profile。 |
| Platform Config | `platform_dict_group`、`platform_dict_item`、`platform_component_template`、`platform_setting` | 字典和组件模板从 Admin 杂项中抽离。字典用 group/item 替代当前扁平重载行。 |
| Blog Content | `blog_post`、`blog_taxonomy`、`blog_term`、`blog_post_term`、`blog_theme_profile`、`blog_import_job` | 分类、标签使用关系表,不再依赖逗号或长文本字段。主题配置改为可命名 profile。 |
| WordPress Mirror | `wordpress_site`、`wordpress_auth_session`、`wordpress_remote_post`、`wordpress_remote_term`、`wordpress_sync_job`、`wordpress_sync_mapping` | 远端 WordPress 状态与本地 Blog 内容分离。mapping 表连接远端 ID 和本地 post/term。 |
| Asset/MinIO | `asset_bucket`、`asset_object`、`asset_reference`、`asset_access_grant` | 统一记录对象归属、来源模块、MIME/type 元数据和临时访问授权,避免 MinIO URL 散落在业务表。 |
| System Event | `system_notice`、`system_event`、`system_event_dedupe`、`system_event_delivery` | Loki 仍是日志查询源。MySQL 只保存可处理通知、去重状态和通知投递状态。 |
| Runtime Evidence | `runtime_evidence_index` | 只存重要运行证据文件的安全索引,不存完整日志和 secrets。大 JSON 证据仍保留在 `.kt-workspace/test-artifacts` 或部署产物存储。 |
| QQBot Core | `qqbot_account`、`qqbot_connection_session`、`qqbot_capability_binding`、`qqbot_permission_policy`、`qqbot_command`、`qqbot_command_alias`、`qqbot_rule`、`qqbot_conversation`、`qqbot_message`、`qqbot_send_task`、`qqbot_send_log`、`qqbot_dedupe_event` | 拆分账号身份、连接态、权限、路由、会话、消息历史和发送队列/历史。 |
| NapCat Runtime | `napcat_container`、`napcat_device_identity`、`napcat_account_binding`、`napcat_login_session`、`napcat_login_challenge`、`napcat_runtime_cleanup` | 设备身份是一等表,记录 MAC、hostname、machine-id 路径、data dir 和验证状态。登录 challenge 覆盖验证码和新设备验证。清理记录用于阻断假成功。 |
| QQBot Plugin Platform | `qqbot_plugin`、`qqbot_plugin_version`、`qqbot_plugin_installation`、`qqbot_plugin_operation`、`qqbot_plugin_event_handler`、`qqbot_plugin_account_binding`、`qqbot_plugin_config`、`qqbot_plugin_asset`、`qqbot_plugin_runtime_event` | 插件 metadata、版本、operation、event、账号绑定、配置、资产和运行事件属于平台不属于某个插件硬编码。 |
| Plugin-Owned Data | `qqbot_plugin_data_*` 或插件命名空间表 | 插件 migration 可创建命名空间表。表名必须以 plugin key 或已注册 namespace 开头,避免冲突。 |
### 破坏式重建策略
实现阶段可按以下流程重建数据库:
1. 对线上数据库做带时间戳的备份。
2. 在破坏式 schema 动作期间停止或限制 API 写流量。
3. 按已批准脚本 drop 或 rename 旧 API 表。
4. 应用新的全量初始化 SQL。
5. 初始化必要 Admin 用户、角色、菜单、平台设置、字典、核心 QQBot 插件 metadata 和默认在线命令。
6. 让 API 使用新 schema 启动。
7. 运行本地或线上 smokeAdmin 登录、菜单加载、Blog 公开读取、MinIO check、QQBot command registry、plugin registry、NapCat 账号状态、`/health/runtime`。
8. 关键 smoke 失败时,恢复备份或重新应用旧 schema bundle并回滚到上一个 API 镜像。
## QQBot 插件平台
QQBot 插件升级为平台能力,不再是硬编码 Nest service 注册。命令插件、事件插件、
渲染插件、外部查询插件和自动化插件使用同一套 contract。
### 插件包结构
```text
plugins/<pluginKey>/
plugin.json
src/
index.ts
operations/
events/
config/
migrations/
assets/
tests/
```
`plugin.json` 是插件元信息的单一来源:
- plugin key、名称、描述、版本、作者、license 和主页。
- 最低 API plugin SDK 版本。
- 请求的 host 权限。
- operations 和 event handlers。
- config schema 和默认值。
- assets 声明。
- migrations 声明。
- runtime 要求,例如 timeout、memory limit 和 worker type。
### Worker 隔离运行时
已确认的运行模型是进程隔离:
- API 主进程负责安装包、校验 manifest、维护 registry、路由 command/event。
- 插件代码运行在独立 worker 或 child process 中。
- 插件崩溃只将插件实例标记为 `degraded``offline`,不拖垮 API 主进程。
- 启用、禁用、升级、卸载会启动或停止 worker并在运行时刷新 registry。
- Worker 和 API 通过窄 RPC 协议通信。
RPC 协议至少支持:
- `load`:加载 manifest 和编译入口。
- `activate`:初始化运行态。
- `deactivate`:停止接收新任务。
- `executeOperation`:执行命令或查询能力。
- `handleEvent`:处理消息或账号事件。
- `health`:返回插件健康状态。
- `dispose`:关闭前释放资源。
RPC 消息携带 operation ID、correlation ID、timeout budget、脱敏后的输入和结构化输出。
超时由 host 统一控制。
### 插件 SDK 边界
插件代码不能访问 Nest DI、TypeORM 原始 Repository、后端原始环境变量或任意文件路径。
插件只能拿到受控 SDK
- 通过 host 发送队列发送 QQBot 消息。
- 通过插件配置服务读写插件配置。
- 读写插件自有 storage。
- 发送插件运行事件。
- 请求 host 提供的 runtime HTTP 能力,带 timeout 和安全证据。
- 从插件 asset root 加载已声明静态资源。
- 读取当前 operation 或 event context。
插件必须在安装前声明权限。Manifest 请求未支持权限,或代码包 hash 与校验记录不一致时,
host 必须拒绝安装或启用。
### CLI 脚手架
仓库应新增插件作者 CLI
```bash
pnpm qqbot-plugin create <pluginKey>
pnpm qqbot-plugin validate <path>
pnpm qqbot-plugin pack <path>
pnpm qqbot-plugin install-local <package>
```
`create` 自动生成:
- `plugin.json`
- 带 `createPlugin()``src/index.ts`
- 一个 operation handler 模板。
- 一个 event handler 模板。
- config schema 和默认值。
- migration/schema 模板。
- contract tests。
- package metadata。
- README/API 片段草稿。
`validate` 校验 manifest shape、operation key、event key、权限、schema 文件、migrations、
包大小、禁用路径和基础测试是否存在。
`pack` 输出带版本和 content hash 的插件包。
`install-local` 使用与线上安装相同的校验链路,将插件包安装到本地开发插件根目录。
### 线上安装和热插拔状态机
插件安装状态流转:
```text
uploaded -> validated -> installed -> enabled
enabled -> disabled
installed -> uninstalled
enabled -> upgrading -> enabled
enabled -> failed
```
Admin 上传插件包后API 校验 manifest、包 hash、版本兼容、权限、migration 和 assets。
插件包保存到受控运行时插件目录,不能写入 `src/`
启用插件时启动 worker注册 operations 和 event handlers并记录运行状态。禁用插件时
停止 worker并从路由中移除 active operation/event不删除数据。卸载插件时停止 worker、
移除 registry、解绑 command/event然后按数据策略处理插件数据默认保留只有管理员
明确选择清理时才删除。
### 现有插件重写计划
BangDream 作为大型参考插件。现有业务能力拆分可以保留在插件包内:
```text
bangDream/
operations/
song/
card/
character/
event/
gacha/
player/
cutoff/
provider/
renderer/
theme/
assets/
```
现有 BangDream operation registry 改为由 manifest 支撑 operation metadata。
handlerName 仍作为 worker 内部实现细节。现有图片 smoke 预期继续保留,包括
event stage 拆图输出行为。
FF14 Market 改为外部查询插件,通过 host runtime HTTP SDK 调用 XIVAPI 和 Universalis。
FFLogs 改为外部查询插件,通过 host runtime HTTP SDK 调用 GraphQL/token并通过插件
config 引用客户端凭据。
Repeater 改为事件插件,由 host 管理账号绑定,由插件持有 transient state且必须走 host
发送队列,不能直接绕过限流和发送排队。
## 模块迁移矩阵
后续实现阶段拆成可审计批次:
| 批次 | 范围 | 结果 |
| --- | --- | --- |
| 0 | 迁移准备 | 新 schema map、全量初始化 SQL 计划、破坏式重建脚本计划、备份/恢复命令、验证 SQL、模块模板和 breaking-change 清单。 |
| 1 | Runtime/Common | 保持 runtime foundation 稳定,补齐模块 adapter 需要的 runtime client/evidence 原语,收缩 common 到稳定共享能力。 |
| 2 | Admin/Auth/Platform Config | 重建身份、菜单、权限、字典、组件模板和系统通知模型。Admin 登录和菜单加载必须先通过。 |
| 3 | Blog/WordPress/Asset | 重建 Blog 内容关系、WordPress mirror/sync 和 MinIO asset 归属。Blog 公开读取和 Admin Blog 管理必须通过。 |
| 4 | QQBot Core | 重建账号、连接、权限、命令、规则、会话、消息和发送队列模型。命令匹配和发送队列测试必须通过。 |
| 5 | QQBot Plugin Platform | 新增插件 manifest 校验、数据库 registry、CLI 脚手架、worker runtime、RPC、线上安装、热插拔状态和 Admin/API 管理契约。 |
| 6 | Existing Plugin Rewrite | 将 BangDream、FF14 Market、FFLogs、Repeater 重写为隔离插件包。现有命令 smoke 行为必须继续可用。 |
| 7 | NapCat Runtime | 在新 QQBot/NapCat 模型上实现容器设备持久化、登录 session 状态机、验证码/新设备流程和清理证据。 |
| 8 | 线上闭环 | 发布后观察 Jenkins/K8s运行 `/health/runtime`、Admin smoke、Blog smoke、插件安装/启用 smoke、QQBot command smoke 和真实 NapCat 账号登录 smoke。 |
NapCat 故意排在 QQBot Core 和 Plugin Platform 后面。它依赖新账号模型,并和 command/event
路由共同参与线上验证,但它本身不是插件。
## 契约和破坏兼容策略
默认保持外部 API 兼容。任何实现任务如果改变 route path、请求字段、响应字段、状态语义、
SSE 事件名、Admin API wrapper 或 SQL seed 标识,都必须在编码前写入 breaking-change 表。
允许的计划内破坏:
- 数据库表名、字段、索引和初始化 SQL 可以自由改变,因为 schema 从头设计。
- Admin 页面可能需要在实现阶段同步 API wrapper。
- 插件管理接口是新增能力,可以使用新的 route shape。
必须保护的行为:
- Admin 登录和菜单加载。
- 业务 API 的 Vben 响应 wrapper。
- `/health/runtime` plain JSON。
- Blog public article list/detail。
- QQBot command test flow。
- QQBot 账号状态继续区分 OneBot 连接、容器、WebUI 和 QQ 登录态。
- NapCat 登录安全校验、验证码回交、新设备验证和运行态密码清理失败阻断成功语义。
## 验证策略
本规划阶段验证:
- spec 自检:未决标记、内部矛盾和模糊范围。
- `git diff --check`
- KT 文档同步检查。
- KT global review 扫描变更文件。
后续实现阶段必须包含:
- domain policy 和状态机的定向单测。
- 新 SQL 和 TypeORM mapping 的 repository/schema 测试。
- CLI 生成的插件 contract tests。
- worker runtime 的 load、activate、execute、health、deactivate 和崩溃隔离测试。
- 变更接口的真实本地 API 请求。
- Admin caller 变化时的 Admin UI smoke。
- 破坏式线上数据库动作前,本地数据库重建 dry run。
- 线上备份、重建、恢复路径、Jenkins/K8s 观测和功能 smoke。
## 本规划阶段验收标准
本阶段完成条件:
1. 设计文档写入 `docs/superpowers/specs/`
2. 设计文档已提交。
3. 用户 review 已提交文档。
4. 下一步进入 Superpowers `writing-plans`,不是直接实现代码。
5. 后续完整迁移开始前,重新走 Superpowers brainstorming。
## 交接到下一阶段
用户 review 通过后,`writing-plans` 阶段应产出计划文档,把本文转换成可执行 work packages、
schema 文档、插件平台任务和验证清单。
后续完整实现必须重新 brainstorming因为它会涉及破坏式 schema 工作、Admin/API 契约决策、
插件运行时安全边界和线上闭环。

View File

@ -0,0 +1,521 @@
# API/Admin 第三期全量重构设计
## 背景
第一期已经完成 Runtime Foundation 与线上部署观测铺底,第二期完成了 API 全模块架构、全库表设计、QQBot 插件平台和 NapCat Runtime 的中文规划。第三期不再只修 QQBot也不是继续追加局部补丁而是进入用户确认的 C 阶段:按第二期规划完整迁移 API 全模块,并同步重构 Admin 管理端。
当前业务数据不重要,因此第三期允许从头设计数据库表,允许破坏式重建 schema允许重写旧初始化 SQL 和旧模块结构。破坏式不等于无保护:本阶段必须把备份、回滚、验证 SQL、本地 dry run、线上发布观测和真实功能 smoke 作为同一条闭环的一部分。
本文件是 Superpowers brainstorming 的第三期设计落地文档。它只固化设计和执行边界,不创建实现分支,不清理 Admin 旧产物,不写业务实现。用户 review 本文件后,下一步进入 Superpowers `writing-plans`
## 已确认决策
- 第三期范围是完整 C 阶段迁移,必须覆盖 API 全模块,不只做 Batch 0-2。
- API 与 Admin 一起重构。API 仓库使用 `dev/api-full-refactor-v3`Admin 仓库使用 `dev/admin-full-refactor-v3`
- 当前 Admin 仓库未提交改动属于旧产物,用户已授权在实现准备阶段放弃;设计阶段先不清理。
- 每个批次在本地完成 TDD、验证、review 和提交,不按批次推送。
- Batch 0-8 全部完成并通过本地闭环后,再统一推送、重建数据库、观察 Jenkins/K8s、执行线上完整 smoke。
- 数据库按全新 schema 设计,可破坏式重建,但必须有备份、恢复路径、验证 SQL 和 API 镜像回滚策略。
- QQBot 插件平台是统一平台能力,必须支持 manifest、CLI 脚手架、线上安装、热插拔、worker 或 child process 隔离、配置、健康、运行事件和账号绑定。
- NapCat Runtime 不是插件。它属于 QQBot/NapCat 基础运行时,必须独立实现设备身份持久化和登录状态机。
- NapCat 新设备验证必须实现 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`,不能只把 `jumpUrl` 透给 Admin。
- SSE/Admin 必须展示完整中文进度:快速登录、密码登录、验证码、新设备二维码、已扫码、确认中、登录成功、登录失败。
## 范围
### API 仓库
第三期 API 重构覆盖:
- `runtime``common` 基础层。
- Admin/Auth/Platform Config。
- Blog/WordPress/Asset。
- QQBot Core。
- QQBot Plugin Platform。
- 现有 QQBot 插件重写。
- NapCat Runtime。
- 全量初始化 SQL、schema 文档、验证 SQL、备份和恢复脚本。
- 本地、发布、线上 smoke 和 KT/Superpowers review 证据。
### Admin 仓库
第三期 Admin 重构覆盖:
- 登录、菜单、权限、用户、角色、部门等身份和权限页面。
- 平台设置、字典、组件模板、系统通知。
- Blog/WordPress/Asset 管理页。
- QQBot 账号、连接、命令、规则、消息、发送队列管理页。
- QQBot 插件管理页:上传、校验、安装、启用、禁用、升级、卸载、配置、健康、运行事件、账号绑定。
- NapCat 登录管理页:设备身份、登录 session、验证码、新设备二维码、SSE 中文进度、清理失败提示。
Admin 不做独立产品改版。UI 调整服务于新 API 契约、状态语义和管理流程。
## 非目标
- 本设计阶段不写实现代码。
- 本设计阶段不创建 `dev/api-full-refactor-v3``dev/admin-full-refactor-v3` 分支。
- 本设计阶段不清理 Admin 旧未提交改动。
- 不保留旧表结构来迁就旧数据。
- 不把 NapCat 登录安全校验做成自动绕过。
- 不允许线上插件直接访问后端真实密钥、TypeORM Repository、Nest DI 或任意宿主文件路径。
- 不把 Jenkins/K8s 成功当成功能完成。线上真实 smoke 是完成条件的一部分。
## 分支和仓库策略
实现准备阶段按顺序处理:
1. 确认 API 仓库状态,基于当前 `main` 创建 `dev/api-full-refactor-v3`
2. 确认 Admin 仓库状态,放弃用户已授权的旧未提交产物后,基于当前 `main` 创建 `dev/admin-full-refactor-v3`
3. API 与 Admin 分别提交,提交信息使用英文 type 前缀加中文说明。
4. 每个批次可以产生 API 提交、Admin 提交或两者都有的提交。
5. Batch 8 本地闭环完成前不推送。
6. 统一推送前再次确认两仓 diff、提交序列、数据库破坏式操作范围和回滚入口。
跨仓依赖以 API 契约为中心API 批次先定义 contract、schema 和状态语义Admin 同批次或紧随其后完成 caller、页面状态和 smoke。任何 breaking change 必须在批次开始前写入 breaking-change 清单。
## 目标模块架构
API 目标结构继承第二期规划:
```text
src/
runtime/
common/
modules/
admin/
blog/
wordpress/
asset/
qqbot/
```
模块内部使用稳定分层:
```text
module/
contract/
application/
domain/
infrastructure/
persistence/
integration/
schema/
tests/
```
- `contract`Controller、DTO、Swagger、SSE 事件和外部兼容适配。
- `application`:用例编排、事务、权限、状态流转和跨端契约。
- `domain`:纯规则、状态机、策略和值对象,不依赖 Nest、TypeORM、Docker 或 HTTP。
- `infrastructure/persistence`Entity、Repository、查询模型和 schema mapper。
- `infrastructure/integration`WordPress、MinIO、Loki、OneBot、NapCat WebUI、Docker、插件 worker RPC。
- `schema`:表设计、初始化 SQL、验证 SQL、备份恢复说明和批次迁移记录。
- `tests`unit、contract、repository、integration smoke 和回归用例。
Admin 目标结构以功能域组织 API caller、页面、组件和状态适配避免把 QQBot、插件平台、NapCat 状态继续塞进单一大页面。
## 全库表重建设计
第三期以全新 schema 为目标,不做旧表兼容迁移。表设计必须在 Batch 0 冻结第一版,并在每个后续批次按模块补齐实现。
全局约定:
- 主键使用 Snowflake `BIGINT`API 边界按字符串语义处理。
- 外键列使用 `*_id`,查询路径必须建索引。
- 业务强关系使用唯一约束,日志和事件类表避免强外键。
- 时间字段使用 `create_time`、`update_time`,软删使用 `delete_time`
- 状态字段使用明确 varchar 枚举,允许值写入 schema 文档。
- JSON 字段只放外部原始 payload、低频配置、插件 metadata 或证据详情;需要查询的字段必须结构化。
- 初始化 SQL 是全量干净 schema不继续堆叠历史 `ALTER TABLE`
核心数据域:
| 数据域 | 表设计方向 |
| --- | --- |
| Admin Identity | 用户、角色、权限、菜单、部门、用户角色、角色权限、角色菜单分离,菜单路由和权限原子拆清。 |
| Platform Config | 字典组、字典项、组件模板、平台设置、系统通知独立成域。 |
| Blog Content | 文章、分类法、术语、文章术语关系、主题 profile、导入 job 分离。 |
| WordPress Mirror | 站点、认证会话、远端文章、远端术语、同步 job、远近端 mapping 分离。 |
| Asset/MinIO | bucket、object、reference、access grant 记录对象归属、MIME、来源模块和临时授权。 |
| System Event | notice、event、dedupe、delivery 保存可处理事件和投递状态Loki 仍是日志查询源。 |
| Runtime Evidence | 保存重要运行证据索引,不把大日志和 secrets 放入 MySQL。 |
| QQBot Core | 账号、连接 session、能力绑定、权限策略、命令、别名、规则、会话、消息、发送任务、发送日志、去重事件分离。 |
| NapCat Runtime | 容器、设备身份、账号绑定、登录 session、登录 challenge、清理记录分离。 |
| QQBot Plugin Platform | 插件、版本、安装、operation、event handler、账号绑定、配置、资产、运行事件分离。 |
| Plugin-Owned Data | 插件自有表必须带 plugin key 或注册 namespace 前缀。 |
破坏式重建流程必须包含:
1. 本地空库 dry run应用全量 SQL、seed、验证 SQL、API 启动、关键接口 smoke。
2. 线上备份:备份当前 API 数据库,记录备份路径、时间、库名和恢复命令。
3. 写流量限制:在 drop 或 rename 旧表前停止或限制 API 写流量。
4. 应用新 schema按批准脚本重建表、索引、seed、插件 metadata 和菜单权限。
5. 绑定 API 镜像schema 版本和 API 镜像作为同一回滚单元。
6. 线上验证Admin、Blog、Asset、QQBot、插件平台、NapCat、`/health/runtime` smoke。
7. 失败恢复:恢复备份或切回旧 schema bundle并回滚 API/Admin 镜像。
## QQBot 插件平台设计
插件平台是第三期独立核心能力。它不是把现有插件目录挪位置,而是建立统一 contract、安装链路、运行隔离和 Admin 管理面。
### 插件包结构
```text
plugins/<pluginKey>/
plugin.json
src/
index.ts
operations/
events/
config/
migrations/
assets/
tests/
```
`plugin.json` 是单一事实来源,包含:
- plugin key、名称、描述、版本、作者、license、主页。
- 最低 API plugin SDK 版本。
- 权限声明。
- operations、event handlers、config schema、assets、migrations。
- runtime 要求timeout、memory limit、worker type、并发策略。
### CLI 脚手架
API 仓库提供插件 CLI
```bash
pnpm qqbot-plugin create <pluginKey>
pnpm qqbot-plugin validate <path>
pnpm qqbot-plugin pack <path>
pnpm qqbot-plugin install-local <package>
```
`create` 生成初始插件模块结构、manifest、入口、operation 模板、event 模板、config schema、migration 模板、contract tests 和说明草稿。
`validate` 校验 manifest shape、operation key、event key、权限声明、migration、assets、包大小、禁用路径和基础测试。
`pack` 输出带版本和 content hash 的插件包。
`install-local` 走与线上安装相同的校验链路,避免本地与线上行为分叉。
### 运行隔离
插件代码运行在 worker 或 child process 中。API 主进程只负责安装包、校验、registry、路由、权限和受控 SDK。
RPC 协议必须支持:
- `load`
- `activate`
- `deactivate`
- `executeOperation`
- `handleEvent`
- `health`
- `dispose`
插件崩溃、超时或健康失败只影响对应插件 installation不能拖垮 API 主进程。Host 记录插件 runtime event并把状态暴露给 Admin。
插件 SDK 只能提供受控能力:
- 走 host 发送队列发送 QQBot 消息。
- 读写插件配置。
- 读写插件自有 storage。
- 发送插件运行事件。
- 通过 host runtime HTTP client 调外部 API。
- 从声明 asset root 读取资源。
- 读取当前 operation 或 event context。
插件不得读取真实环境变量、任意宿主文件、Nest DI、TypeORM 原始 Repository 或跨插件私有数据。
### 线上安装和热插拔
状态机:
```text
uploaded -> validated -> installed -> enabled
enabled -> disabled
installed -> uninstalled
enabled -> upgrading -> enabled
enabled -> failed
```
Admin 上传插件包后API 负责校验 manifest、hash、版本兼容、权限、migration 和 assets。启用时启动 worker 并注册 operation/event。禁用时停止 worker 并从 active registry 移除。卸载时停止 worker、移除 registry 和绑定,默认保留插件数据,只有管理员明确选择清理才删除插件自有数据。
### 现有插件重写
- BangDream作为大型参考插件重写保留 song、card、character、event、gacha、player、cutoff、provider、renderer、theme、assets 等能力边界operation metadata 由 manifest 支撑,图片 smoke 行为必须保留。
- FF14 Market外部查询插件通过 host runtime HTTP SDK 调用 XIVAPI 和 Universalis。
- FFLogs外部查询插件通过 host runtime HTTP SDK 调 GraphQL/token凭据来自插件 config 的受控引用。
- Repeater事件插件由 host 管理账号绑定和发送队列,插件只维护自身策略状态,不能绕过限流和队列。
## NapCat Runtime 设计
NapCat Runtime 排在 QQBot Core 和插件平台之后实现,但它不是插件。它属于账号、容器、设备身份和登录安全流程的基础运行时。
目标:
- Docker 设备状态持久化,避免每次重建容器都被 QQ 识别成新设备。
- 设备身份是一等数据容器名、data dir、hostname、machine-id、MAC、账号绑定、验证状态和最后登录证据必须可追踪。
- 登录状态机保持清晰:快速登录、密码登录、验证码、新设备验证、二维码 fallback、成功、失败、清理失败各自独立。
- 新设备验证按 NapCat 上游流程完成:`GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`。
- SSE/Admin 全中文进度可观察,用户能知道当前卡在扫码、确认、验证码、清理失败还是最终失败。
- 运行态密码清理失败必须阻断成功或二维码 fallback并写入明确错误不被普通离线原因覆盖。
登录顺序:
```text
quick login -> saved password login -> captcha challenge -> new device QR challenge -> manual QR fallback
```
验证码处理:
- `proofWaterUrl` 只作为用户完成腾讯验证码的入口。
- 用户提交 `ticket`、`randstr`、`sid` 后API 调 NapCat `CaptchaLogin`
- 不伪造验证码票据,不绕过腾讯安全校验。
- 一旦 session 进入 captcha pending后续状态缺少 URL 也不能直接失败,必须保留 challenge 语义。
新设备验证处理:
- `CaptchaLogin` 或登录状态返回 `needNewDevice`API 创建或更新 new-device challenge。
- API 调 NapCat `GetNewDeviceQRCode` 获取新设备二维码,不只透传 `jumpUrl`
- API 周期调用 `PollNewDeviceQR`,把未扫码、已扫码、确认中、过期、失败状态映射为中文 SSE/Admin 状态。
- 用户确认后 API 调 `NewDeviceLogin`,同一登录 session 继续完成登录。
- 新设备验证成功后保留设备身份证据,后续容器重建复用持久化设备状态。
SSE/Admin 状态至少覆盖:
- 正在快速登录。
- 快速登录失败,进入密码登录。
- 正在密码登录。
- 需要验证码。
- 验证码已提交,等待 NapCat 确认。
- 需要新设备验证二维码。
- 新设备二维码待扫码。
- 新设备二维码已扫码。
- 新设备确认中。
- 新设备验证成功,继续登录。
- 正在生成手动二维码。
- 登录成功。
- 登录失败。
- 运行态清理失败。
## 执行批次
### Batch 0迁移准备
产物:
- API/Admin 双仓分支准备方案。
- Admin 旧产物清理记录。
- 全库 schema map。
- 全量初始化 SQL 草案。
- 破坏式重建脚本设计。
- 备份和恢复命令。
- 验证 SQL。
- 模块模板。
- breaking-change 清单。
- API/Admin 契约矩阵。
验收:
- 空库 schema dry run 可执行。
- 初始 seed 覆盖 Admin 登录、菜单、基础设置、插件 metadata 和 QQBot 基础命令。
- writing-plans 输出的任务能够映射到 Batch 1-8。
### Batch 1Runtime/Common
范围:
- 稳定 runtime profile、runtime client、evidence、timeout、process/Docker adapter、错误分类。
- 收缩 common只保留通用响应、错误、时间、Snowflake、日志基础能力。
验收:
- `/health/runtime` 行为稳定。
- 现有 runtime tests 继续通过。
- 下游模块可以通过统一 adapter 接入外部 HTTP、process、Docker 和证据记录。
### Batch 2Admin/Auth/Platform Config
范围:
- 重建身份、角色、权限、菜单、部门、字典、组件模板、平台设置和系统通知模型。
- Admin 登录、菜单、权限页面同步新 contract。
验收:
- 本地真实登录接口通过。
- Admin 菜单加载和页面路由通过。
- 权限、菜单、字典、设置的增删改查通过 scoped smoke。
### Batch 3Blog/WordPress/Asset
范围:
- 重建 Blog 内容、分类法、术语关系、主题 profile。
- 重建 WordPress mirror、sync job、mapping。
- 重建 MinIO asset 归属、引用和临时授权。
- Admin Blog、WordPress、Asset 页面同步新 contract。
验收:
- Blog public list/detail 本地真实请求通过。
- Admin Blog 管理 smoke 通过。
- WordPress 同步 dry run 或受控 smoke 通过。
- Asset 上传、引用和读取 smoke 通过。
### Batch 4QQBot Core
范围:
- 重建账号、连接 session、能力绑定、权限策略、命令、别名、规则、会话、消息、发送任务、发送日志和去重事件。
- 拆清 OneBot 连接、容器、WebUI 和 QQ 登录态。
- Admin QQBot 基础管理页同步新 contract。
验收:
- 命令 registry 和 command SQL 测试通过。
- 发送队列、去重、权限策略单测通过。
- `/qqbot/command/test` 本地真实请求通过。
- Admin QQBot 账号和命令页面 smoke 通过。
### Batch 5QQBot Plugin Platform
范围:
- 插件 manifest schema。
- 插件 registry 数据库。
- CLI `create/validate/pack/install-local`
- worker 或 child process runtime。
- RPC 协议。
- 线上安装、启用、禁用、升级、卸载。
- 插件配置、健康、运行事件、账号绑定。
- Admin 插件管理页面。
验收:
- CLI 创建插件并通过 validate。
- pack 产物含 hashinstall-local 走统一校验链路。
- worker load、activate、execute、health、deactivate、崩溃隔离测试通过。
- Admin 插件上传、安装、启用、禁用 smoke 通过。
### Batch 6现有插件重写
范围:
- BangDream 重写为平台插件。
- FF14 Market 重写为平台插件。
- FFLogs 重写为平台插件。
- Repeater 重写为平台插件。
- 旧硬编码插件 registry 退出。
验收:
- BangDream 现有核心命令和图片 smoke 通过。
- FF14 Market 查询 smoke 通过。
- FFLogs 查询 smoke 通过。
- Repeater 事件策略和发送队列 smoke 通过。
- Admin 能查看插件 operation、配置、健康和运行事件。
### Batch 7NapCat Runtime
范围:
- Docker 设备状态持久化。
- NapCat 容器、设备身份、账号绑定、登录 session、challenge、cleanup 记录。
- 快速登录、密码登录、验证码、新设备验证、二维码 fallback 状态机。
- `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`
- SSE/Admin 中文进度。
- 清理失败阻断成功。
验收:
- 本地 NapCat login session 状态机单测通过。
- API 真实本地请求覆盖验证码和新设备 challenge 模拟。
- Admin 登录页面进度 smoke 通过。
- 容器重建后设备身份持久化证据可查询。
### Batch 8统一上线闭环
范围:
- API/Admin 最终本地验证。
- 两仓统一推送。
- 数据库备份、重建、seed、验证 SQL。
- Jenkins/K8s 观测。
- 线上完整 smoke。
- 真实 QQBot/NapCat 账号闭环验证。
验收:
- Jenkins build number、commit hash、镜像 tag、Deployment generation、Pod image、restart count 和日志证据齐全。
- `/health/runtime` 线上通过。
- Admin 登录、菜单、关键管理页线上通过。
- Blog public 线上通过。
- 插件安装、启用、健康、命令线上通过。
- `/qqbot/command/test` 线上按 operationKey 查询 commandId 后通过。
- NapCat 真实账号完成登录闭环,包含设备持久化和新设备验证链路。
## 每批验证和提交节奏
每个批次必须按同一节奏执行:
1. RED先写或定位失败检查包括单测、schema 验证、contract smoke 或 Admin smoke。
2. GREEN做最小足够实现。
3. 本地验证:按批次风险运行 typecheck、scoped lint、scoped Jest、真实 API 请求和 Admin smoke。
4. 文档同步:更新 schema、README/API、TASKS 或相关 docs。
5. KT global review。
6. Superpowers code review。
7. 批次提交API/Admin 分仓提交。
接口变化必须真实本地调用一次。发布或线上功能必须通过对应线上 self-test 后才能声明完成。
## 推送和上线策略
第三期本地实现期间不推送。Batch 0-8 全部本地完成后,统一执行:
1. 双仓最终 `git status` 和 diff 复核。
2. 本地完整轻量验证矩阵。
3. 用户确认推送和破坏式数据库动作窗口。
4. 推送 API 和 Admin 分支或目标分支。
5. 确认 Jenkins build number 和 commit hash。
6. 观察 K8s Deployment、Pod、image tag、restart count 和日志。
7. 线上数据库备份、重建、seed、验证 SQL。
8. 线上 API/Admin/Blog/QQBot/插件/NapCat smoke。
如果线上 smoke 失败,按 schema 与 API 镜像绑定回滚:恢复数据库备份或旧 schema bundle回滚 API/Admin 镜像,记录失败点和稳定解法。
## 风险和控制
- 范围过大:通过 Batch 0-8 拆分,每批有独立 RED/GREEN、review、commit 和验收。
- 数据库破坏式重建风险:本地空库 dry run、线上备份、恢复命令、验证 SQL 和镜像绑定回滚。
- API/Admin 契约漂移:每批维护 contract matrix接口变化必须真实调用Admin 同步 smoke。
- 插件安全风险manifest 权限、hash 校验、受控 SDK、worker 隔离、运行事件和禁用路径校验。
- NapCat 状态混淆QQ 登录态、OneBot 连接、容器、WebUI、验证码、新设备、清理失败分别建模。
- 线上成功误判Jenkins/K8s 只算部署证据,功能完成必须有线上 smoke。
## 本设计验收标准
本设计阶段完成条件:
1. 本文件写入 `docs/superpowers/specs/`
2. 本文件通过占位、矛盾、范围和模糊性自检。
3. `TASKS.md` 记录第三期设计上下文。
4. API 和根仓库文档变更分别提交。
5. 用户 review 本文件。
6. 用户确认后进入 Superpowers `writing-plans`,再产出可执行计划。
## 交接到 writing-plans
`writing-plans` 必须把本设计转换为可执行计划,至少包含:
- Batch 0-8 逐批任务清单。
- 每批 RED/GREEN 测试入口。
- API/Admin 双仓文件范围。
- 表设计和初始化 SQL 产物路径。
- 破坏式重建、备份、恢复、验证 SQL 细节。
- 插件 CLI、worker runtime、Admin 插件管理页任务拆分。
- NapCat 设备持久化和新设备验证任务拆分。
- 每批提交、review、清理、文档同步和最终上线闭环门禁。
在用户 review 本文件前,不进入实现,也不进入分支清理或 Admin 旧产物处理。

View File

@ -22,6 +22,7 @@
"typecheck": "tsc --noEmit",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"qqbot-plugin": "ts-node -r tsconfig-paths/register scripts/qqbot-plugin/cli.ts",
"verify:staged": "node scripts/husky-fast-check.mjs",
"verify:commit": "pnpm run lint && pnpm run typecheck",
"test": "jest",

View File

@ -48,8 +48,8 @@ const path = require("path");
const { ConfigService } = require("@nestjs/config");
const payload = JSON.parse(Buffer.from("$PayloadBase64", "base64").toString("utf8"));
const { ToolsService } = require("./src/common/services/tool.service");
const { QqbotBangDreamRendererService } = require("./src/qqbot/plugins/bangDream/application/bangdream-renderer.facade");
const { TsuguApplicationService } = require("./src/qqbot/plugins/bangDream/application/bangdream-application.service");
const { QqbotBangDreamRendererService } = require("./src/modules/qqbot/plugins/bangDream/application/bangdream-renderer.facade");
const { TsuguApplicationService } = require("./src/modules/qqbot/plugins/bangDream/application/bangdream-application.service");
(async () => {
const renderer = new QqbotBangDreamRendererService(new ConfigService({}), undefined);

312
scripts/qqbot-plugin/cli.ts Normal file
View File

@ -0,0 +1,312 @@
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import {
parseQqbotPluginManifest,
type QqbotPluginManifest,
} from '../../src/modules/qqbot/plugin-platform/manifest';
export type QqbotPluginCliCommand =
| 'create'
| 'install-local'
| 'pack'
| 'validate';
export type QqbotPluginCliOptions = {
cwd?: string;
stderr?: (message: string) => void;
stdout?: (message: string) => void;
};
export type QqbotPluginCliResult = {
command: QqbotPluginCliCommand;
exitCode: number;
packageHash?: string;
packagePath?: string;
pluginKey?: string;
pluginRoot?: string;
version?: string;
};
type PackedPluginFile = {
path: string;
sha256: string;
};
type PackedPlugin = {
contentHash: string;
files: PackedPluginFile[];
manifest: QqbotPluginManifest;
};
const templateRoot = path.join(__dirname, 'templates', 'basic');
const packageExtension = '.qqbot-plugin.json';
const sha256 = (content: Buffer | string) =>
crypto.createHash('sha256').update(content).digest('hex');
const stableStringify = (value: unknown): string => {
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(',')}]`;
}
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.filter((key) => record[key] !== undefined)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
.join(',')}}`;
}
return JSON.stringify(value);
};
const formatPluginName = (pluginKey: string) => {
return pluginKey
.split('-')
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join(' ');
};
const readManifest = (pluginRoot: string) => {
const manifestPath = path.join(pluginRoot, 'plugin.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as unknown;
return parseQqbotPluginManifest(manifest, { pluginRoot });
};
const copyTemplateFile = (
sourcePath: string,
targetPath: string,
replacements: Record<string, string>,
) => {
const content = fs.readFileSync(sourcePath);
const textContent = content.toString('utf8');
const rendered = Object.entries(replacements).reduce(
(result, [key, value]) => result.replaceAll(key, value),
textContent,
);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, rendered);
};
const copyTemplateDirectory = (
sourceRoot: string,
targetRoot: string,
replacements: Record<string, string>,
) => {
for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) {
const sourcePath = path.join(sourceRoot, entry.name);
const targetPath = path.join(targetRoot, entry.name);
if (entry.isDirectory()) {
copyTemplateDirectory(sourcePath, targetPath, replacements);
continue;
}
copyTemplateFile(sourcePath, targetPath, replacements);
}
};
const listPackageFiles = (pluginRoot: string): PackedPluginFile[] => {
const files: PackedPluginFile[] = [];
const ignoredRoots = new Set(['dist', 'node_modules']);
const visit = (current: string) => {
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
if (current === pluginRoot && ignoredRoots.has(entry.name)) continue;
const absolutePath = path.join(current, entry.name);
if (entry.isDirectory()) {
visit(absolutePath);
continue;
}
const relativePath = path
.relative(pluginRoot, absolutePath)
.replace(/\\/g, '/');
files.push({
path: relativePath,
sha256: sha256(fs.readFileSync(absolutePath)),
});
}
};
visit(pluginRoot);
return files.sort((a, b) => a.path.localeCompare(b.path));
};
const buildPackedPlugin = (pluginRoot: string): PackedPlugin => {
const manifest = readManifest(pluginRoot);
const files = listPackageFiles(pluginRoot);
const contentHash = sha256(
stableStringify({
files,
manifest,
}),
);
return {
contentHash,
files,
manifest,
};
};
const assertPackageIntegrity = (packedPlugin: PackedPlugin) => {
const expectedHash = sha256(
stableStringify({
files: packedPlugin.files,
manifest: packedPlugin.manifest,
}),
);
if (packedPlugin.contentHash !== expectedHash) {
throw new Error('QQBot plugin package content hash mismatch.');
}
parseQqbotPluginManifest(packedPlugin.manifest);
};
const createPlugin = (
pluginKey: string | undefined,
options: Required<QqbotPluginCliOptions>,
): QqbotPluginCliResult => {
if (!pluginKey) throw new Error('Usage: qqbot-plugin create <pluginKey>');
const pluginRoot = path.join(options.cwd, 'plugins', pluginKey);
if (fs.existsSync(pluginRoot)) {
throw new Error(`Plugin already exists: ${pluginRoot}`);
}
copyTemplateDirectory(templateRoot, pluginRoot, {
__PLUGIN_KEY__: pluginKey,
__PLUGIN_NAME__: formatPluginName(pluginKey),
});
fs.mkdirSync(path.join(pluginRoot, 'assets'), { recursive: true });
fs.mkdirSync(path.join(pluginRoot, 'migrations'), { recursive: true });
fs.mkdirSync(path.join(pluginRoot, 'tests'), { recursive: true });
readManifest(pluginRoot);
options.stdout(`Created QQBot plugin: ${pluginRoot}`);
return {
command: 'create',
exitCode: 0,
pluginKey,
pluginRoot,
version: '0.1.0',
};
};
const validatePlugin = (
pluginRootArg: string | undefined,
options: Required<QqbotPluginCliOptions>,
): QqbotPluginCliResult => {
if (!pluginRootArg) throw new Error('Usage: qqbot-plugin validate <path>');
const pluginRoot = path.resolve(options.cwd, pluginRootArg);
const manifest = readManifest(pluginRoot);
options.stdout(
`Validated QQBot plugin: ${manifest.pluginKey}@${manifest.version}`,
);
return {
command: 'validate',
exitCode: 0,
pluginKey: manifest.pluginKey,
pluginRoot,
version: manifest.version,
};
};
const packPlugin = (
pluginRootArg: string | undefined,
options: Required<QqbotPluginCliOptions>,
): QqbotPluginCliResult => {
if (!pluginRootArg) throw new Error('Usage: qqbot-plugin pack <path>');
const pluginRoot = path.resolve(options.cwd, pluginRootArg);
const packedPlugin = buildPackedPlugin(pluginRoot);
const shortHash = packedPlugin.contentHash.slice(0, 12);
const packagePath = path.join(
pluginRoot,
'dist',
`${packedPlugin.manifest.pluginKey}-${packedPlugin.manifest.version}-${shortHash}${packageExtension}`,
);
fs.mkdirSync(path.dirname(packagePath), { recursive: true });
fs.writeFileSync(packagePath, `${JSON.stringify(packedPlugin, null, 2)}\n`);
options.stdout(`Packed QQBot plugin: ${packagePath}`);
return {
command: 'pack',
exitCode: 0,
packageHash: packedPlugin.contentHash,
packagePath,
pluginKey: packedPlugin.manifest.pluginKey,
pluginRoot,
version: packedPlugin.manifest.version,
};
};
const installLocalPlugin = (
packagePathArg: string | undefined,
options: Required<QqbotPluginCliOptions>,
): QqbotPluginCliResult => {
if (!packagePathArg) {
throw new Error('Usage: qqbot-plugin install-local <package>');
}
const packagePath = path.resolve(options.cwd, packagePathArg);
const packedPlugin = JSON.parse(
fs.readFileSync(packagePath, 'utf8'),
) as PackedPlugin;
assertPackageIntegrity(packedPlugin);
options.stdout(
`Installed local QQBot plugin package: ${packedPlugin.manifest.pluginKey}@${packedPlugin.manifest.version}`,
);
return {
command: 'install-local',
exitCode: 0,
packageHash: packedPlugin.contentHash,
packagePath,
pluginKey: packedPlugin.manifest.pluginKey,
version: packedPlugin.manifest.version,
};
};
export const runQqbotPluginCli = async (
argv: string[],
options: QqbotPluginCliOptions = {},
): Promise<QqbotPluginCliResult> => {
const command = argv[0] as QqbotPluginCliCommand | undefined;
const resolvedOptions: Required<QqbotPluginCliOptions> = {
cwd: options.cwd || process.cwd(),
stderr: options.stderr || ((message) => console.error(message)),
stdout: options.stdout || ((message) => console.log(message)),
};
switch (command) {
case 'create':
return createPlugin(argv[1], resolvedOptions);
case 'install-local':
return installLocalPlugin(argv[1], resolvedOptions);
case 'pack':
return packPlugin(argv[1], resolvedOptions);
case 'validate':
return validatePlugin(argv[1], resolvedOptions);
default:
throw new Error(
'Usage: qqbot-plugin <create|validate|pack|install-local> [args]',
);
}
};
if (require.main === module) {
runQqbotPluginCli(process.argv.slice(2)).catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
}

View File

@ -0,0 +1,54 @@
{
"pluginKey": "__PLUGIN_KEY__",
"name": "__PLUGIN_NAME__",
"description": "Generated QQBot plugin",
"version": "0.1.0",
"author": "KT",
"license": "UNLICENSED",
"homepage": "",
"minApiSdkVersion": "1.0.0",
"entry": "src/index.ts",
"permissions": ["qqbot.send", "plugin.config.read", "plugin.storage.write"],
"operations": [
{
"key": "__PLUGIN_KEY__.echo",
"name": "Echo",
"description": "Echo input text",
"handlerName": "echo",
"aliases": ["/__PLUGIN_KEY__"],
"permissions": ["qqbot.send"],
"timeoutMs": 3000,
"inputSchema": {
"type": "object",
"properties": {
"text": {
"type": "string"
}
}
},
"outputSchema": {
"type": "object"
}
}
],
"events": [
{
"key": "__PLUGIN_KEY__.message",
"name": "Message Event",
"eventName": "message.created",
"handlerName": "onMessage"
}
],
"configSchema": {
"type": "object",
"properties": {}
},
"assets": [],
"migrations": [],
"runtime": {
"workerType": "node-worker",
"timeoutMs": 5000,
"memoryMb": 128,
"maxConcurrency": 1
}
}

View File

@ -0,0 +1,11 @@
export async function echo(input: { text?: string }) {
return {
replyText: input.text || 'pong',
};
}
export async function onMessage() {
return {
handled: false,
};
}

View File

@ -0,0 +1,14 @@
param(
[Parameter(Mandatory = $true)][string]$Database,
[Parameter(Mandatory = $true)][string]$OutputDirectory
)
$ErrorActionPreference = "Stop"
if ($Database -notmatch '^[A-Za-z0-9_]+$') {
throw "Database must match ^[A-Za-z0-9_]+$"
}
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
$target = Join-Path $OutputDirectory "$Database-refactor-v3-$stamp.sql"
mysqldump --set-gtid-purged=OFF --single-transaction --routines --triggers --default-character-set=utf8mb4 "--result-file=$target" $Database
Write-Output $target

View File

@ -0,0 +1,41 @@
param(
[Parameter(Mandatory = $true)][string]$Database,
[string]$HostName = "127.0.0.1",
[int]$Port = 3306,
[string]$User = "root"
)
$ErrorActionPreference = "Stop"
if ($Database -notmatch '^[A-Za-z0-9_]+$') {
throw "Database must match ^[A-Za-z0-9_]+$"
}
$root = Resolve-Path (Join-Path $PSScriptRoot "..\..")
$schema = Join-Path $root "sql\refactor-v3\00-full-schema.sql"
$seed = Join-Path $root "sql\refactor-v3\01-seed-core.sql"
$verify = Join-Path $root "sql\refactor-v3\99-verify.sql"
function Invoke-Mysql {
param(
[Parameter(Mandatory = $true)][string[]]$Arguments
)
mysql --default-character-set=utf8mb4 @Arguments
if ($LASTEXITCODE -ne 0) {
throw "mysql failed with exit code $LASTEXITCODE"
}
}
function Invoke-MysqlSource {
param(
[Parameter(Mandatory = $true)][string]$Path
)
$sourcePath = (Resolve-Path -LiteralPath $Path).Path.Replace("\", "/")
Invoke-Mysql -Arguments @("-h", $HostName, "-P", "$Port", "-u", $User, $Database, "--execute=source $sourcePath")
}
Invoke-Mysql -Arguments @("-h", $HostName, "-P", "$Port", "-u", $User, "-e", "DROP DATABASE IF EXISTS ``$Database``; CREATE DATABASE ``$Database`` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;")
Invoke-MysqlSource -Path $schema
Invoke-MysqlSource -Path $seed
Invoke-MysqlSource -Path $verify

View File

@ -0,0 +1,16 @@
param(
[Parameter(Mandatory = $true)][string]$Database,
[Parameter(Mandatory = $true)][string]$BackupFile
)
$ErrorActionPreference = "Stop"
if ($Database -notmatch '^[A-Za-z0-9_]+$') {
throw "Database must match ^[A-Za-z0-9_]+$"
}
if (-not (Test-Path -LiteralPath $BackupFile -PathType Leaf)) {
throw "BackupFile does not exist"
}
$sourcePath = (Resolve-Path -LiteralPath $BackupFile).Path.Replace("\", "/")
mysql -e "DROP DATABASE IF EXISTS ``$Database``; CREATE DATABASE ``$Database`` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mysql --default-character-set=utf8mb4 $Database --execute="source $sourcePath"

View File

@ -0,0 +1,10 @@
param(
[string]$BaseUrl = "http://127.0.0.1:48085"
)
$ErrorActionPreference = "Stop"
$runtime = Invoke-RestMethod -Method Get -Uri "$BaseUrl/health/runtime" -TimeoutSec 10
if (-not $runtime.service) {
throw "Runtime health response did not include service"
}
Write-Output "runtime.service=$($runtime.service)"

View File

@ -0,0 +1,987 @@
CREATE TABLE IF NOT EXISTS admin_menu (
id BIGINT NOT NULL PRIMARY KEY,
pid BIGINT NOT NULL DEFAULT 0,
name VARCHAR(120) NOT NULL,
path VARCHAR(255) NULL,
component VARCHAR(255) NULL,
redirect VARCHAR(255) NULL,
auth_code VARCHAR(120) NULL,
type VARCHAR(32) NOT NULL DEFAULT 'menu',
meta LONGTEXT NULL,
status INT NOT NULL DEFAULT 1,
sort INT NOT NULL DEFAULT 0,
is_deleted TINYINT NOT NULL DEFAULT 0,
create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
UNIQUE KEY uk_admin_menu_name (name),
KEY idx_admin_menu_pid (pid),
KEY idx_admin_menu_path (path),
KEY idx_admin_menu_auth_code (auth_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS admin_role (
id BIGINT NOT NULL PRIMARY KEY,
role_code VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
status INT NOT NULL DEFAULT 1,
is_deleted TINYINT NOT NULL DEFAULT 0,
create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
UNIQUE KEY uk_admin_role_code (role_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS admin_user (
id BIGINT NOT NULL PRIMARY KEY,
username VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL,
real_name VARCHAR(255) NOT NULL,
avatar VARCHAR(1024) NOT NULL DEFAULT '',
dept_id BIGINT NULL,
home_path VARCHAR(255) NOT NULL DEFAULT '',
timezone VARCHAR(255) NOT NULL DEFAULT 'Asia/Shanghai',
status INT NOT NULL DEFAULT 1,
is_deleted TINYINT NOT NULL DEFAULT 0,
create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
UNIQUE KEY uk_admin_user_username (username),
KEY idx_admin_user_dept_id (dept_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS admin_dept (
id BIGINT NOT NULL PRIMARY KEY,
pid BIGINT NOT NULL DEFAULT 0,
name VARCHAR(255) NOT NULL,
status INT NOT NULL DEFAULT 1,
remark VARCHAR(255) NOT NULL DEFAULT '',
is_deleted TINYINT NOT NULL DEFAULT 0,
create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
KEY idx_admin_dept_pid (pid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS admin_dict (
id BIGINT NOT NULL PRIMARY KEY,
dict_code VARCHAR(255) NOT NULL,
label VARCHAR(255) NOT NULL,
value VARCHAR(255) NOT NULL,
children_code VARCHAR(255) NULL,
sort INT NOT NULL DEFAULT 0,
status INT NOT NULL DEFAULT 1,
is_deleted TINYINT NOT NULL DEFAULT 0,
create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
UNIQUE KEY uk_admin_dict_code_value (dict_code, value),
KEY idx_admin_dict_code (dict_code),
KEY idx_admin_dict_children_code (children_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS admin_component (
id BIGINT NOT NULL PRIMARY KEY,
name VARCHAR(255) NOT NULL DEFAULT '',
type INT NOT NULL,
component_type INT NOT NULL,
image MEDIUMTEXT NOT NULL,
template MEDIUMTEXT NOT NULL,
is_deleted TINYINT NOT NULL DEFAULT 0,
create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
KEY idx_admin_component_type (type),
KEY idx_admin_component_component_type (component_type),
KEY idx_admin_component_deleted (is_deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS admin_notice (
id BIGINT NOT NULL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content LONGTEXT NOT NULL,
summary TEXT NULL,
level INT NOT NULL DEFAULT 1,
status INT NOT NULL DEFAULT 1,
severity VARCHAR(16) NOT NULL DEFAULT 'info',
source VARCHAR(64) NOT NULL DEFAULT 'system',
event_type VARCHAR(120) NOT NULL DEFAULT 'system.event',
dedupe_key VARCHAR(255) NULL,
occurrence_count INT NOT NULL DEFAULT 1,
notify_role_code VARCHAR(64) NOT NULL DEFAULT 'super',
metadata JSON NULL,
is_top TINYINT NOT NULL DEFAULT 0,
notify_users TEXT NULL,
created_by BIGINT NULL,
is_deleted TINYINT NOT NULL DEFAULT 0,
active_dedupe_key VARCHAR(255) GENERATED ALWAYS AS (CASE WHEN is_deleted = 0 AND dedupe_key IS NOT NULL THEN dedupe_key ELSE NULL END) VIRTUAL,
create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
first_seen_at DATETIME NULL,
last_seen_at DATETIME NULL,
UNIQUE KEY uk_admin_notice_active_dedupe_key (active_dedupe_key),
KEY idx_admin_notice_status (status),
KEY idx_admin_notice_severity (severity),
KEY idx_admin_notice_source_event (source, event_type),
KEY idx_admin_notice_dedupe_key (dedupe_key),
KEY idx_admin_notice_notify_role (notify_role_code),
KEY idx_admin_notice_is_top (is_top),
KEY idx_admin_notice_is_deleted (is_deleted),
KEY idx_admin_notice_create_time (create_time),
KEY idx_admin_notice_last_seen (last_seen_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS admin_user_role (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
PRIMARY KEY (user_id, role_id),
KEY idx_admin_user_role_role_id (role_id),
CONSTRAINT fk_admin_user_role_user FOREIGN KEY (user_id) REFERENCES admin_user (id) ON DELETE CASCADE,
CONSTRAINT fk_admin_user_role_role FOREIGN KEY (role_id) REFERENCES admin_role (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS admin_role_menu (
role_id BIGINT NOT NULL,
menu_id BIGINT NOT NULL,
PRIMARY KEY (role_id, menu_id),
KEY idx_admin_role_menu_menu_id (menu_id),
CONSTRAINT fk_admin_role_menu_role FOREIGN KEY (role_id) REFERENCES admin_role (id) ON DELETE CASCADE,
CONSTRAINT fk_admin_role_menu_menu FOREIGN KEY (menu_id) REFERENCES admin_menu (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS admin_permission (
id BIGINT NOT NULL PRIMARY KEY,
permission_key VARCHAR(128) NOT NULL,
permission_name VARCHAR(128) NOT NULL,
module_key VARCHAR(64) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_admin_permission_key (permission_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS admin_department (
id BIGINT NOT NULL PRIMARY KEY,
parent_id BIGINT NULL,
dept_name VARCHAR(128) NOT NULL,
sort_no INT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_admin_department_parent (parent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS admin_role_permission (
id BIGINT NOT NULL PRIMARY KEY,
role_id BIGINT NOT NULL,
permission_id BIGINT NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_admin_role_permission (role_id, permission_id),
KEY idx_admin_role_permission_permission (permission_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS platform_dict_group (
id BIGINT NOT NULL PRIMARY KEY,
group_key VARCHAR(128) NOT NULL,
group_name VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_platform_dict_group_key (group_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS platform_dict_item (
id BIGINT NOT NULL PRIMARY KEY,
group_id BIGINT NOT NULL,
item_key VARCHAR(128) NOT NULL,
item_label VARCHAR(128) NOT NULL,
item_value VARCHAR(255) NOT NULL,
sort_no INT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_platform_dict_item (group_id, item_key),
KEY idx_platform_dict_item_group (group_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS platform_component_template (
id BIGINT NOT NULL PRIMARY KEY,
template_key VARCHAR(128) NOT NULL,
template_name VARCHAR(128) NOT NULL,
schema_json JSON NULL,
status VARCHAR(32) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_platform_component_template_key (template_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS platform_setting (
id BIGINT NOT NULL PRIMARY KEY,
setting_key VARCHAR(128) NOT NULL,
setting_value TEXT NULL,
value_type VARCHAR(32) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_platform_setting_key (setting_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS blog_post (
id BIGINT NOT NULL PRIMARY KEY,
slug VARCHAR(255) NOT NULL,
title VARCHAR(255) NOT NULL,
summary TEXT NULL,
content_markdown LONGTEXT NULL,
content_html LONGTEXT NULL,
status VARCHAR(32) NOT NULL,
publish_time DATETIME NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_blog_post_slug (slug),
KEY idx_blog_post_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS blog_article (
id BIGINT NOT NULL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'draft',
excerpt TEXT NULL,
content_markdown MEDIUMTEXT NULL,
content_html MEDIUMTEXT NULL,
cover TEXT NULL,
author_name VARCHAR(255) NOT NULL DEFAULT 'KwiTsukasa',
category_items TEXT NULL,
tag_items TEXT NULL,
views INT NOT NULL DEFAULT 0,
comments INT NOT NULL DEFAULT 0,
is_deleted TINYINT NOT NULL DEFAULT 0,
publish_time DATETIME NULL,
create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
KEY idx_blog_article_slug (slug),
KEY idx_blog_article_status (status),
KEY idx_blog_article_publish_time (publish_time),
KEY idx_blog_article_is_deleted (is_deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS blog_taxonomy (
id BIGINT NOT NULL PRIMARY KEY,
taxonomy_key VARCHAR(64) NOT NULL,
taxonomy_name VARCHAR(128) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_blog_taxonomy_key (taxonomy_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS blog_term (
id BIGINT NOT NULL PRIMARY KEY,
taxonomy_id BIGINT NULL,
kind VARCHAR(32) NOT NULL,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL,
term_name VARCHAR(128) NULL,
description TEXT NULL,
parent_id VARCHAR(64) NULL,
is_deleted TINYINT NOT NULL DEFAULT 0,
create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
KEY idx_blog_term_taxonomy (taxonomy_id),
KEY idx_blog_term_kind_slug (kind, slug),
KEY idx_blog_term_parent_id (parent_id),
KEY idx_blog_term_is_deleted (is_deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS blog_post_term (
id BIGINT NOT NULL PRIMARY KEY,
post_id BIGINT NOT NULL,
term_id BIGINT NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_blog_post_term (post_id, term_id),
KEY idx_blog_post_term_term (term_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS blog_theme_profile (
id BIGINT NOT NULL PRIMARY KEY,
profile_key VARCHAR(128) NOT NULL,
profile_name VARCHAR(128) NOT NULL,
config_json JSON NOT NULL,
enabled TINYINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_blog_theme_profile_key (profile_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS blog_theme_config (
id VARCHAR(64) NOT NULL PRIMARY KEY,
config LONGTEXT NOT NULL,
source VARCHAR(255) NOT NULL DEFAULT 'local',
create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS blog_import_job (
id BIGINT NOT NULL PRIMARY KEY,
source_key VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
started_at DATETIME NULL,
finished_at DATETIME NULL,
summary_json JSON NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_blog_import_job_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS wordpress_site (
id BIGINT NOT NULL PRIMARY KEY,
site_key VARCHAR(128) NOT NULL,
base_url VARCHAR(512) NOT NULL,
status VARCHAR(32) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_wordpress_site_key (site_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS wordpress_auth_session (
id BIGINT NOT NULL PRIMARY KEY,
site_id BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
expires_at DATETIME NULL,
safe_summary JSON NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_wordpress_auth_session_site (site_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS wordpress_remote_post (
id BIGINT NOT NULL PRIMARY KEY,
site_id BIGINT NOT NULL,
remote_id VARCHAR(64) NOT NULL,
slug VARCHAR(255) NOT NULL,
status VARCHAR(32) NOT NULL,
raw_payload JSON NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_wordpress_remote_post (site_id, remote_id),
KEY idx_wordpress_remote_post_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS wordpress_remote_term (
id BIGINT NOT NULL PRIMARY KEY,
site_id BIGINT NOT NULL,
remote_id VARCHAR(64) NOT NULL,
taxonomy_key VARCHAR(64) NOT NULL,
slug VARCHAR(255) NOT NULL,
raw_payload JSON NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_wordpress_remote_term (site_id, taxonomy_key, remote_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS wordpress_sync_job (
id BIGINT NOT NULL PRIMARY KEY,
site_id BIGINT NOT NULL,
job_type VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
started_at DATETIME NULL,
finished_at DATETIME NULL,
summary_json JSON NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_wordpress_sync_job_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS wordpress_sync_mapping (
id BIGINT NOT NULL PRIMARY KEY,
site_id BIGINT NOT NULL,
remote_type VARCHAR(64) NOT NULL,
remote_id VARCHAR(64) NOT NULL,
local_type VARCHAR(64) NOT NULL,
local_id BIGINT NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_wordpress_sync_mapping (site_id, remote_type, remote_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS asset_bucket (
id BIGINT NOT NULL PRIMARY KEY,
bucket_key VARCHAR(128) NOT NULL,
bucket_name VARCHAR(128) NOT NULL,
provider VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_asset_bucket_key (bucket_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS asset_object (
id BIGINT NOT NULL PRIMARY KEY,
bucket_id BIGINT NOT NULL,
object_key VARCHAR(512) NOT NULL,
source_module VARCHAR(64) NOT NULL,
mime_type VARCHAR(128) NULL,
size_bytes BIGINT NULL,
status VARCHAR(32) NOT NULL,
metadata_json JSON NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_asset_object_key (bucket_id, object_key),
KEY idx_asset_object_source (source_module)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS asset_reference (
id BIGINT NOT NULL PRIMARY KEY,
object_id BIGINT NOT NULL,
owner_module VARCHAR(64) NOT NULL,
owner_type VARCHAR(64) NOT NULL,
owner_id BIGINT NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_asset_reference (object_id, owner_module, owner_type, owner_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS asset_access_grant (
id BIGINT NOT NULL PRIMARY KEY,
object_id BIGINT NOT NULL,
grant_token VARCHAR(128) NOT NULL,
expires_at DATETIME NOT NULL,
status VARCHAR(32) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_asset_access_grant_token (grant_token),
KEY idx_asset_access_grant_object (object_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS system_notice (
id BIGINT NOT NULL PRIMARY KEY,
notice_key VARCHAR(128) NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
level VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_system_notice_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS system_event (
id BIGINT NOT NULL PRIMARY KEY,
event_key VARCHAR(128) NOT NULL,
source_module VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload_json JSON NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_system_event_key (event_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS system_event_dedupe (
id BIGINT NOT NULL PRIMARY KEY,
dedupe_key VARCHAR(255) NOT NULL,
first_seen_at DATETIME NOT NULL,
last_seen_at DATETIME NOT NULL,
hit_count INT NOT NULL DEFAULT 1,
UNIQUE KEY uk_system_event_dedupe_key (dedupe_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS system_event_delivery (
id BIGINT NOT NULL PRIMARY KEY,
event_id BIGINT NOT NULL,
delivery_target VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
attempt_count INT NOT NULL DEFAULT 0,
last_error TEXT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_system_event_delivery_event (event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS runtime_evidence_index (
id BIGINT NOT NULL PRIMARY KEY,
evidence_key VARCHAR(128) NOT NULL,
evidence_type VARCHAR(64) NOT NULL,
artifact_path VARCHAR(512) NOT NULL,
safe_summary JSON NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_runtime_evidence_key (evidence_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_account (
id BIGINT NOT NULL PRIMARY KEY,
self_id VARCHAR(64) NOT NULL,
connection_mode VARCHAR(32) NOT NULL DEFAULT 'reverse-ws',
name VARCHAR(120) NOT NULL DEFAULT '',
display_name VARCHAR(128) NULL,
access_token VARCHAR(255) NULL,
enabled TINYINT NOT NULL DEFAULT 1,
connect_status VARCHAR(32) NOT NULL DEFAULT 'offline',
onebot_status VARCHAR(32) NOT NULL DEFAULT 'offline',
container_status VARCHAR(32) NOT NULL DEFAULT 'unknown',
webui_status VARCHAR(32) NOT NULL DEFAULT 'unknown',
qq_login_status VARCHAR(32) NOT NULL DEFAULT 'unknown',
client_role VARCHAR(32) NULL,
last_connected_at DATETIME NULL,
last_heartbeat_at DATETIME NULL,
last_error TEXT NULL,
napcat_login_password_secret VARCHAR(1024) NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
is_deleted TINYINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_account_self_id (self_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_account_ability (
id BIGINT NOT NULL PRIMARY KEY,
account_id BIGINT NOT NULL,
self_id VARCHAR(64) NOT NULL,
ability_type VARCHAR(32) NOT NULL,
ability_key VARCHAR(128) NOT NULL,
is_deleted TINYINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_account_ability (account_id, ability_type, ability_key),
KEY idx_qqbot_account_ability_self (self_id, ability_type, is_deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_connection_session (
id BIGINT NOT NULL PRIMARY KEY,
account_id BIGINT NOT NULL,
session_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
connected_at DATETIME NULL,
disconnected_at DATETIME NULL,
close_reason TEXT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_connection_session_key (session_key),
KEY idx_qqbot_connection_session_account (account_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_capability_binding (
id BIGINT NOT NULL PRIMARY KEY,
account_id BIGINT NOT NULL,
capability_key VARCHAR(128) NOT NULL,
enabled TINYINT NOT NULL DEFAULT 1,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_capability_binding (account_id, capability_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_permission_policy (
id BIGINT NOT NULL PRIMARY KEY,
policy_key VARCHAR(128) NOT NULL,
scope_type VARCHAR(64) NOT NULL,
scope_value VARCHAR(128) NOT NULL,
effect VARCHAR(32) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_permission_policy (policy_key, scope_type, scope_value)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_allowlist (
id BIGINT NOT NULL PRIMARY KEY,
self_id VARCHAR(64) NOT NULL DEFAULT '',
target_type VARCHAR(32) NOT NULL DEFAULT 'qq',
target_id VARCHAR(64) NOT NULL DEFAULT '',
user_id VARCHAR(64) NOT NULL DEFAULT '',
precise_user TINYINT NOT NULL DEFAULT 0,
enabled TINYINT NOT NULL DEFAULT 1,
remark VARCHAR(255) NOT NULL DEFAULT '',
is_deleted TINYINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_blocklist (
id BIGINT NOT NULL PRIMARY KEY,
self_id VARCHAR(64) NOT NULL DEFAULT '',
target_type VARCHAR(32) NOT NULL DEFAULT 'qq',
target_id VARCHAR(64) NOT NULL DEFAULT '',
user_id VARCHAR(64) NOT NULL DEFAULT '',
precise_user TINYINT NOT NULL DEFAULT 0,
enabled TINYINT NOT NULL DEFAULT 1,
remark VARCHAR(255) NOT NULL DEFAULT '',
is_deleted TINYINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_command (
id BIGINT NOT NULL PRIMARY KEY,
operation_key VARCHAR(128) NOT NULL,
command_key VARCHAR(128) NULL,
code VARCHAR(80) NOT NULL,
name VARCHAR(120) NOT NULL DEFAULT '',
aliases TEXT NOT NULL,
prefixes VARCHAR(120) NOT NULL DEFAULT '/,!,',
plugin_key VARCHAR(128) NULL,
parser_key VARCHAR(40) NOT NULL DEFAULT 'plain',
target_type VARCHAR(32) NOT NULL DEFAULT 'all',
default_params TEXT NULL,
reply_template TEXT NULL,
error_template TEXT NULL,
enabled TINYINT NOT NULL DEFAULT 1,
priority INT NOT NULL DEFAULT 0,
cooldown_ms INT NOT NULL DEFAULT 1500,
cooldown_seconds INT NOT NULL DEFAULT 0,
last_hit_at DATETIME NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
is_deleted TINYINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_command_key (command_key),
KEY idx_qqbot_command_operation (operation_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_command_alias (
id BIGINT NOT NULL PRIMARY KEY,
command_id BIGINT NOT NULL,
alias_text VARCHAR(128) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_command_alias (command_id, alias_text)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_command_log (
id BIGINT NOT NULL PRIMARY KEY,
command_id VARCHAR(64) NOT NULL,
command_code VARCHAR(80) NOT NULL DEFAULT '',
plugin_key VARCHAR(80) NOT NULL,
operation_key VARCHAR(120) NOT NULL,
self_id VARCHAR(64) NOT NULL DEFAULT '',
target_type VARCHAR(32) NOT NULL DEFAULT 'private',
target_id VARCHAR(64) NOT NULL DEFAULT '',
user_id VARCHAR(64) NOT NULL DEFAULT '',
raw_message TEXT NOT NULL,
input TEXT NULL,
output TEXT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'success',
error_message TEXT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_config (
id BIGINT NOT NULL PRIMARY KEY,
config_key VARCHAR(120) NOT NULL,
config_value TEXT NOT NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_config_key (config_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_rule (
id BIGINT NOT NULL PRIMARY KEY,
rule_key VARCHAR(128) NULL,
name VARCHAR(120) NOT NULL DEFAULT '',
match_type VARCHAR(32) NOT NULL DEFAULT 'keyword',
keyword VARCHAR(500) NOT NULL,
target_type VARCHAR(32) NOT NULL DEFAULT 'all',
reply_content TEXT NOT NULL,
account_id BIGINT NULL,
command_id BIGINT NULL,
matcher_json JSON NULL,
action_json JSON NULL,
enabled TINYINT NOT NULL DEFAULT 1,
priority INT NOT NULL DEFAULT 0,
cooldown_ms INT NOT NULL DEFAULT 1500,
last_hit_at DATETIME NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
is_deleted TINYINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_rule_key (rule_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_conversation (
id BIGINT NOT NULL PRIMARY KEY,
account_id BIGINT NULL,
self_id VARCHAR(64) NOT NULL,
conversation_type VARCHAR(64) NULL,
conversation_key VARCHAR(128) NULL,
target_type VARCHAR(32) NOT NULL,
target_id VARCHAR(64) NOT NULL,
display_name VARCHAR(255) NULL,
target_name VARCHAR(120) NOT NULL DEFAULT '',
last_message_id VARCHAR(64) NULL,
last_message_text TEXT NOT NULL,
last_message_time DATETIME NULL,
message_count INT NOT NULL DEFAULT 0,
is_deleted TINYINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_conversation (account_id, conversation_type, conversation_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_message (
id BIGINT NOT NULL PRIMARY KEY,
account_id BIGINT NULL,
self_id VARCHAR(64) NOT NULL,
conversation_id BIGINT NULL,
message_id VARCHAR(128) NULL,
direction VARCHAR(32) NOT NULL,
message_type VARCHAR(64) NOT NULL,
target_id VARCHAR(64) NOT NULL,
group_id VARCHAR(64) NULL,
user_id VARCHAR(64) NOT NULL,
sender_nickname VARCHAR(120) NOT NULL DEFAULT '',
raw_message TEXT NOT NULL,
message_text TEXT NOT NULL,
summary TEXT NULL,
raw_payload JSON NULL,
raw_event JSON NULL,
event_time DATETIME NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_message (account_id, message_id),
KEY idx_qqbot_message_conversation (conversation_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_send_task (
id BIGINT NOT NULL PRIMARY KEY,
account_id BIGINT NULL,
conversation_id BIGINT NULL,
task_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
payload_json JSON NOT NULL,
reserved_at DATETIME NULL,
sent_at DATETIME NULL,
last_error TEXT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_send_task_key (task_key),
KEY idx_qqbot_send_task_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_send_log (
id BIGINT NOT NULL PRIMARY KEY,
task_id BIGINT NULL,
account_id BIGINT NULL,
self_id VARCHAR(64) NOT NULL,
target_type VARCHAR(32) NOT NULL,
target_id VARCHAR(64) NOT NULL,
action VARCHAR(64) NOT NULL,
message_text TEXT NOT NULL,
params JSON NULL,
status VARCHAR(32) NOT NULL,
echo VARCHAR(80) NULL,
message_id VARCHAR(64) NULL,
safe_summary JSON NULL,
error_message TEXT NULL,
response JSON NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_qqbot_send_log_task (task_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_dedupe_event (
id BIGINT NOT NULL PRIMARY KEY,
dedupe_key VARCHAR(255) NOT NULL,
account_id BIGINT NULL,
expires_at DATETIME NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_dedupe_event_key (dedupe_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_dedupe (
id BIGINT NOT NULL PRIMARY KEY,
event_key VARCHAR(255) NOT NULL,
expire_at DATETIME NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_dedupe_event_key (event_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_plugin (
id BIGINT NOT NULL PRIMARY KEY,
plugin_key VARCHAR(128) NOT NULL,
plugin_name VARCHAR(128) NOT NULL,
description TEXT NULL,
status VARCHAR(32) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_plugin_key (plugin_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_plugin_version (
id BIGINT NOT NULL PRIMARY KEY,
plugin_id BIGINT NOT NULL,
version VARCHAR(64) NOT NULL,
package_hash VARCHAR(128) NOT NULL,
manifest_json JSON NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_plugin_version (plugin_id, version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_plugin_installation (
id BIGINT NOT NULL PRIMARY KEY,
plugin_id BIGINT NOT NULL,
version_id BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
runtime_status VARCHAR(32) NOT NULL,
installed_path VARCHAR(512) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_qqbot_plugin_installation_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_plugin_operation (
id BIGINT NOT NULL PRIMARY KEY,
plugin_id BIGINT NOT NULL,
operation_key VARCHAR(128) NOT NULL,
operation_name VARCHAR(128) NOT NULL,
handler_name VARCHAR(128) NOT NULL,
enabled TINYINT NOT NULL DEFAULT 1,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_plugin_operation (plugin_id, operation_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_plugin_event_handler (
id BIGINT NOT NULL PRIMARY KEY,
plugin_id BIGINT NOT NULL,
event_key VARCHAR(128) NOT NULL,
handler_name VARCHAR(128) NOT NULL,
enabled TINYINT NOT NULL DEFAULT 1,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_plugin_event_handler (plugin_id, event_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_plugin_account_binding (
id BIGINT NOT NULL PRIMARY KEY,
plugin_id BIGINT NOT NULL,
account_id BIGINT NOT NULL,
enabled TINYINT NOT NULL DEFAULT 1,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_plugin_account_binding (plugin_id, account_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_plugin_config (
id BIGINT NOT NULL PRIMARY KEY,
plugin_id BIGINT NOT NULL,
config_key VARCHAR(128) NOT NULL,
config_value JSON NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_plugin_config (plugin_id, config_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_plugin_asset (
id BIGINT NOT NULL PRIMARY KEY,
plugin_id BIGINT NOT NULL,
asset_key VARCHAR(255) NOT NULL,
asset_path VARCHAR(512) NOT NULL,
content_hash VARCHAR(128) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_plugin_asset (plugin_id, asset_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_plugin_runtime_event (
id BIGINT NOT NULL PRIMARY KEY,
plugin_id BIGINT NOT NULL,
installation_id BIGINT NULL,
event_type VARCHAR(64) NOT NULL,
level VARCHAR(32) NOT NULL,
safe_summary JSON NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_qqbot_plugin_runtime_event_plugin (plugin_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS napcat_container (
id BIGINT NOT NULL PRIMARY KEY,
account_id BIGINT NOT NULL,
container_name VARCHAR(128) NOT NULL,
image_name VARCHAR(255) NOT NULL,
status VARCHAR(32) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_napcat_container_name (container_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_account_napcat (
id BIGINT NOT NULL PRIMARY KEY,
account_id BIGINT NOT NULL,
container_id BIGINT NOT NULL,
bind_status VARCHAR(32) NOT NULL DEFAULT 'pending',
is_primary TINYINT NOT NULL DEFAULT 1,
last_login_at DATETIME NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
is_deleted TINYINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_qqbot_account_napcat_account (account_id, is_deleted),
KEY idx_qqbot_account_napcat_container (container_id, is_deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qqbot_napcat_container (
id BIGINT NOT NULL PRIMARY KEY,
name VARCHAR(120) NOT NULL,
base_url VARCHAR(255) NOT NULL,
webui_port INT NULL,
webui_token VARCHAR(255) NULL,
image VARCHAR(255) NOT NULL DEFAULT '',
data_dir VARCHAR(500) NOT NULL DEFAULT '',
reverse_ws_url VARCHAR(500) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'creating',
last_started_at DATETIME NULL,
last_checked_at DATETIME NULL,
last_error VARCHAR(500) NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
is_deleted TINYINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_qqbot_napcat_container_name (name),
KEY idx_qqbot_napcat_container_status (status, is_deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS napcat_device_identity (
id BIGINT NOT NULL PRIMARY KEY,
account_id BIGINT NOT NULL,
container_id BIGINT NULL,
data_dir VARCHAR(512) NOT NULL,
hostname VARCHAR(128) NOT NULL,
machine_id_path VARCHAR(512) NOT NULL,
mac_address VARCHAR(64) NOT NULL,
verification_status VARCHAR(32) NOT NULL,
last_login_evidence JSON NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_napcat_device_identity_account (account_id),
KEY idx_napcat_device_identity_container (container_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS napcat_account_binding (
id BIGINT NOT NULL PRIMARY KEY,
account_id BIGINT NOT NULL,
container_id BIGINT NOT NULL,
device_identity_id BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_napcat_account_binding_account (account_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS napcat_login_session (
id BIGINT NOT NULL PRIMARY KEY,
account_id BIGINT NOT NULL,
session_key VARCHAR(128) NOT NULL,
login_stage VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
progress_message VARCHAR(255) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_napcat_login_session_key (session_key),
KEY idx_napcat_login_session_account (account_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS napcat_login_challenge (
id BIGINT NOT NULL PRIMARY KEY,
session_id BIGINT NOT NULL,
challenge_type VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
challenge_url TEXT NULL,
challenge_payload JSON NULL,
resolved_at DATETIME NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_napcat_login_challenge_session (session_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS napcat_runtime_cleanup (
id BIGINT NOT NULL PRIMARY KEY,
session_id BIGINT NOT NULL,
cleanup_type VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
error_message TEXT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_napcat_runtime_cleanup_session (session_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -0,0 +1,294 @@
INSERT INTO admin_role (
id,
role_code,
name,
remark,
status
) VALUES (
2041700000000010001,
'super',
'超级管理员',
'拥有所有后台权限',
1
) ON DUPLICATE KEY UPDATE
name = VALUES(name),
remark = VALUES(remark),
status = VALUES(status),
is_deleted = 0;
INSERT INTO admin_dept (
id,
pid,
name,
status,
remark
) VALUES (
2041700000000200001,
0,
'KT 总部',
1,
'根部门'
) ON DUPLICATE KEY UPDATE
pid = VALUES(pid),
name = VALUES(name),
status = VALUES(status),
remark = VALUES(remark),
is_deleted = 0;
INSERT INTO admin_user (
id,
username,
password,
real_name,
avatar,
dept_id,
home_path,
timezone,
status
) VALUES (
2041700000000000002,
'admin',
'123456',
'Admin',
'',
2041700000000200001,
'/workspace',
'Asia/Shanghai',
1
) ON DUPLICATE KEY UPDATE
password = VALUES(password),
real_name = VALUES(real_name),
avatar = VALUES(avatar),
dept_id = VALUES(dept_id),
home_path = VALUES(home_path),
timezone = VALUES(timezone),
status = VALUES(status),
is_deleted = 0;
INSERT INTO admin_menu (
id,
pid,
name,
path,
component,
redirect,
auth_code,
type,
meta,
status,
sort
) VALUES
(
2041700000000100001,
0,
'Dashboard',
'/dashboard',
NULL,
'/workspace',
NULL,
'catalog',
'{"order":-1,"title":"page.dashboard.title"}',
1,
0
),
(
2041700000000100102,
2041700000000100001,
'Workspace',
'/workspace',
'/dashboard/workspace/index',
NULL,
NULL,
'menu',
'{"icon":"carbon:workspace","title":"page.dashboard.workspace"}',
1,
0
),
(
2041700000000100400,
0,
'QqBot',
'/qqbot',
NULL,
'/qqbot/dashboard',
NULL,
'catalog',
'{"icon":"lucide:bot","order":110,"title":"QQBot 管理"}',
1,
110
),
(
2041700000000100402,
2041700000000100400,
'QqBotAccount',
'/qqbot/account',
'/qqbot/account/list',
NULL,
'QqBot:Account:List',
'menu',
'{"icon":"lucide:radio-receiver","title":"账号连接"}',
1,
1
),
(
2041700000000100408,
2041700000000100400,
'QqBotCommand',
'/qqbot/command',
'/qqbot/command/list',
NULL,
'QqBot:Command:List',
'menu',
'{"icon":"lucide:square-terminal","title":"在线命令"}',
1,
3
),
(
2041700000000100409,
2041700000000100400,
'QqBotPlugin',
'/qqbot/plugin',
'/qqbot/plugin/list',
NULL,
'QqBot:Plugin:List',
'menu',
'{"icon":"lucide:plug","title":"插件能力"}',
1,
4
)
ON DUPLICATE KEY UPDATE
pid = VALUES(pid),
path = VALUES(path),
component = VALUES(component),
redirect = VALUES(redirect),
auth_code = VALUES(auth_code),
type = VALUES(type),
meta = VALUES(meta),
status = VALUES(status),
sort = VALUES(sort),
is_deleted = 0;
INSERT INTO admin_dict (
id,
dict_code,
label,
value,
children_code,
sort,
status
) VALUES
(
2041700000000300001,
'COMPONENT_TYPE',
'图表',
'1',
'CHART',
1,
1
),
(
2041700000000300101,
'CHART',
'未分类',
'-1',
NULL,
0,
1
)
ON DUPLICATE KEY UPDATE
label = VALUES(label),
children_code = VALUES(children_code),
sort = VALUES(sort),
status = VALUES(status),
is_deleted = 0;
INSERT IGNORE INTO admin_user_role (user_id, role_id)
VALUES (2041700000000000002, 2041700000000010001);
INSERT IGNORE INTO admin_role_menu (role_id, menu_id)
SELECT 2041700000000010001, id
FROM admin_menu
WHERE is_deleted = 0;
INSERT INTO platform_setting (
id,
setting_key,
setting_value,
value_type
) VALUES (
1000000000000000005,
'schema.version',
'refactor-v3',
'string'
) ON DUPLICATE KEY UPDATE
setting_value = VALUES(setting_value),
value_type = VALUES(value_type);
INSERT INTO qqbot_plugin (
id,
plugin_key,
plugin_name,
description,
status
) VALUES
(
1000000000000000101,
'bangdream',
'BangDream',
'Built-in BangDream command plugin metadata.',
'installed'
),
(
1000000000000000102,
'ff14-market',
'FF14 Market',
'Built-in FF14 market command plugin metadata.',
'installed'
),
(
1000000000000000103,
'fflogs',
'FFLogs',
'Built-in FFLogs command plugin metadata.',
'installed'
),
(
1000000000000000104,
'repeater',
'Repeater',
'Built-in repeater event plugin metadata.',
'installed'
)
ON DUPLICATE KEY UPDATE
plugin_name = VALUES(plugin_name),
description = VALUES(description),
status = VALUES(status);
INSERT INTO qqbot_command (
id,
operation_key,
command_key,
code,
name,
aliases,
plugin_key,
enabled,
cooldown_seconds
) VALUES (
1000000000000000201,
'bangdream.song.search',
'bangdream_song',
'bd',
'BangDream 查歌',
'["查曲","bd","bangdream","bandori","邦邦","邦邦查歌"]',
'bangdream',
1,
2
) ON DUPLICATE KEY UPDATE
operation_key = VALUES(operation_key),
code = VALUES(code),
name = VALUES(name),
aliases = VALUES(aliases),
plugin_key = VALUES(plugin_key),
enabled = VALUES(enabled),
cooldown_seconds = VALUES(cooldown_seconds);

View File

@ -0,0 +1,62 @@
SELECT 'admin_user' AS table_name, COUNT(*) AS row_count FROM admin_user;
SELECT 'admin_role' AS table_name, COUNT(*) AS row_count FROM admin_role;
SELECT 'admin_menu' AS table_name, COUNT(*) AS row_count FROM admin_menu;
SELECT 'platform_setting' AS table_name, COUNT(*) AS row_count FROM platform_setting;
SELECT 'admin_dict' AS table_name, COUNT(*) AS row_count FROM admin_dict;
SELECT 'qqbot_command' AS table_name, COUNT(*) AS row_count FROM qqbot_command;
SELECT 'qqbot_plugin' AS table_name, COUNT(*) AS row_count FROM qqbot_plugin;
SELECT 'napcat_device_identity' AS table_name, COUNT(*) AS row_count FROM napcat_device_identity;
SELECT 'seed_admin_user' AS check_name, COUNT(*) AS matched_rows
FROM admin_user
WHERE username = 'admin'
AND password <> ''
AND status = 1
AND is_deleted = 0;
SELECT 'seed_platform_schema_version' AS check_name, COUNT(*) AS matched_rows
FROM platform_setting
WHERE setting_key = 'schema.version'
AND setting_value = 'refactor-v3';
SELECT 'seed_qqbot_plugin_bangdream' AS check_name, COUNT(*) AS matched_rows
FROM qqbot_plugin
WHERE plugin_key = 'bangdream'
AND status = 'installed';
SELECT 'seed_qqbot_command_bangdream_song' AS check_name, COUNT(*) AS matched_rows
FROM qqbot_command
WHERE command_key = 'bangdream_song'
AND operation_key = 'bangdream.song.search'
AND plugin_key = 'bangdream'
AND enabled = 1;
SELECT 'index_admin_user_username' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'admin_user'
AND index_name = 'uk_admin_user_username';
SELECT 'index_platform_setting_key' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'platform_setting'
AND index_name = 'uk_platform_setting_key';
SELECT 'index_admin_dict_code_value' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'admin_dict'
AND index_name = 'uk_admin_dict_code_value';
SELECT 'index_qqbot_command_key' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'qqbot_command'
AND index_name = 'uk_qqbot_command_key';
SELECT 'index_napcat_device_identity_account' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'napcat_device_identity'
AND index_name = 'uk_napcat_device_identity_account';

View File

@ -6,7 +6,6 @@ import { LoggerModule } from 'nestjs-pino';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MinioModule } from 'nestjs-minio-client';
import { MinioClientModule } from './minio/minio.module';
import {
ApiRequestLogInterceptor,
ApiExceptionFilter,
@ -14,10 +13,12 @@ import {
createPinoLoggerParams,
SaveBodyInterceptor,
} from './common';
import { AdminModule } from './admin/admin.module';
import { BlogModule } from './blog/blog.module';
import { WordpressModule } from './wordpress/wordpress.module';
import { QqbotModule } from './qqbot/qqbot.module';
import { AdminModule } from './modules/admin/admin.module';
import { AssetModule } from './modules/asset/asset.module';
import { BlogContentModule } from './modules/blog/blog-content.module';
import { QqbotCoreModule } from './modules/qqbot/core/qqbot-core.module';
import { QqbotPluginPlatformModule } from './modules/qqbot/plugin-platform/plugin-platform.module';
import { WordpressMirrorModule } from './modules/wordpress/wordpress-mirror.module';
import { RuntimeModule } from './runtime';
@Module({
@ -43,7 +44,10 @@ import { RuntimeModule } from './runtime';
password: configService.get('DB_PASSWORD'),
database: configService.get('DB_DATABASE'),
synchronize: configService.get<string>('DB_SYNC') === 'true',
entities: [__dirname + '/**/*.entity{.ts,.js}'],
entities: [
__dirname + '/**/*.entity{.ts,.js}',
__dirname + '/**/*.entities{.ts,.js}',
],
subscribers: [__dirname + '/**/*.subscriber{.ts,.js}'],
};
},
@ -63,13 +67,14 @@ import { RuntimeModule } from './runtime';
},
inject: [ConfigService],
}),
MinioClientModule,
CommonModule,
RuntimeModule,
AdminModule,
BlogModule,
WordpressModule,
QqbotModule,
BlogContentModule,
WordpressMirrorModule,
AssetModule,
QqbotCoreModule,
QqbotPluginPlatformModule,
],
providers: [
AppService,

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { AdminIdentityModule } from './identity/admin-identity.module';
import { AdminPlatformConfigModule } from './platform-config/admin-platform-config.module';
@Module({
imports: [AdminIdentityModule, AdminPlatformConfigModule],
})
export class AdminModule {}

View File

@ -0,0 +1,45 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminAuthGuardModule } from '@/admin/auth/admin-auth-guard.module';
import { AdminAuthController } from '@/admin/auth/admin-auth.controller';
import { AdminDeptController } from '@/admin/dept/admin-dept.controller';
import { AdminDept } from '@/admin/dept/admin-dept.entity';
import { AdminDeptService } from '@/admin/dept/admin-dept.service';
import { AdminMenuController } from '@/admin/menu/admin-menu.controller';
import { AdminMenu } from '@/admin/menu/admin-menu.entity';
import { AdminMenuService } from '@/admin/menu/admin-menu.service';
import { AdminRoleController } from '@/admin/role/admin-role.controller';
import { AdminRole } from '@/admin/role/admin-role.entity';
import { AdminRoleService } from '@/admin/role/admin-role.service';
import { AdminUserManageController } from '@/admin/user/admin-user-manage.controller';
import { AdminUserController } from '@/admin/user/admin-user.controller';
import { AdminUser } from '@/admin/user/admin-user.entity';
import { AdminUserService } from '@/admin/user/admin-user.service';
import { WordpressMirrorModule } from '@/modules/wordpress/wordpress-mirror.module';
export const ADMIN_IDENTITY_CONTROLLERS = [
AdminAuthController,
AdminUserController,
AdminUserManageController,
AdminMenuController,
AdminRoleController,
AdminDeptController,
];
export const ADMIN_IDENTITY_PROVIDERS = [
AdminUserService,
AdminMenuService,
AdminRoleService,
AdminDeptService,
];
@Module({
imports: [
TypeOrmModule.forFeature([AdminUser, AdminRole, AdminMenu, AdminDept]),
AdminAuthGuardModule,
WordpressMirrorModule,
],
controllers: ADMIN_IDENTITY_CONTROLLERS,
providers: ADMIN_IDENTITY_PROVIDERS,
})
export class AdminIdentityModule {}

View File

@ -0,0 +1,53 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminAuthGuardModule } from '@/admin/auth/admin-auth-guard.module';
import { ComponentController } from '@/admin/component/component.controller';
import { Component } from '@/admin/component/component.entity';
import { ComponentService } from '@/admin/component/component.service';
import { DictController } from '@/admin/dict/dict.controller';
import { DictModule } from '@/admin/dict/dict.module';
import { AdminNoticeController } from '@/admin/notice/admin-notice.controller';
import { NoticeModule } from '@/admin/notice/notice.module';
import { SystemLogController } from '@/admin/system-log/system-log.controller';
import { SystemLogService } from '@/admin/system-log/system-log.service';
import { AdminTimezoneController } from '@/admin/timezone/admin-timezone.controller';
import { AdminTimezoneService } from '@/admin/timezone/admin-timezone.service';
import { AdminUser } from '@/admin/user/admin-user.entity';
import { AdminExampleController } from '@/admin/example/admin-example.controller';
import { AssetModule } from '@/modules/asset/asset.module';
export const ADMIN_PLATFORM_CONFIG_DIRECT_CONTROLLERS = [
ComponentController,
SystemLogController,
AdminTimezoneController,
AdminExampleController,
];
export const ADMIN_PLATFORM_CONFIG_IMPORTED_CONTROLLERS = [
DictController,
AdminNoticeController,
];
export const ADMIN_PLATFORM_CONFIG_CONTROLLERS = [
...ADMIN_PLATFORM_CONFIG_DIRECT_CONTROLLERS,
...ADMIN_PLATFORM_CONFIG_IMPORTED_CONTROLLERS,
];
export const ADMIN_PLATFORM_CONFIG_PROVIDERS = [
ComponentService,
SystemLogService,
AdminTimezoneService,
];
@Module({
imports: [
TypeOrmModule.forFeature([Component, AdminUser]),
AdminAuthGuardModule,
DictModule,
NoticeModule,
AssetModule,
],
controllers: ADMIN_PLATFORM_CONFIG_DIRECT_CONTROLLERS,
providers: ADMIN_PLATFORM_CONFIG_PROVIDERS,
})
export class AdminPlatformConfigModule {}

View File

@ -0,0 +1,47 @@
import { Module } from '@nestjs/common';
import { MinioClientController } from '@/minio/minio.controller';
import { MinioClientModule } from '@/minio/minio.module';
import { MinioClientService } from '@/minio/minio.service';
export const ASSET_CONTROLLERS = [MinioClientController];
export const ASSET_PROVIDERS = [MinioClientService];
export const ASSET_DOMAIN_CONTRACT = {
tables: [
'asset_bucket',
'asset_object',
'asset_reference',
'asset_access_grant',
],
objectOwnership: {
objectTable: 'asset_object',
bucketTable: 'asset_bucket',
ownerModuleField: 'source_module',
objectKeyField: 'object_key',
},
mimeMetadata: {
objectTable: 'asset_object',
mimeField: 'mime_type',
sizeField: 'size_bytes',
metadataField: 'metadata_json',
},
reference: {
table: 'asset_reference',
objectKey: 'object_id',
ownerFields: ['owner_module', 'owner_type', 'owner_id'],
},
accessGrant: {
table: 'asset_access_grant',
objectKey: 'object_id',
tokenField: 'grant_token',
expiresAtField: 'expires_at',
statusField: 'status',
},
} as const;
@Module({
imports: [MinioClientModule],
exports: [MinioClientModule],
})
export class AssetModule {}

View File

@ -0,0 +1,66 @@
import { Module } from '@nestjs/common';
import { BlogArticleController } from '@/blog/blog-article.controller';
import { BlogArticleService } from '@/blog/blog-article.service';
import { BlogModule } from '@/blog/blog.module';
import { BlogTermController } from '@/blog/blog-term.controller';
import { BlogTermService } from '@/blog/blog-term.service';
import { BlogThemeConfigController } from '@/blog/blog-theme-config.controller';
import { BlogThemeConfigService } from '@/blog/blog-theme-config.service';
export const BLOG_CONTENT_CONTROLLERS = [
BlogArticleController,
BlogTermController,
BlogThemeConfigController,
];
export const BLOG_CONTENT_PROVIDERS = [
BlogArticleService,
BlogTermService,
BlogThemeConfigService,
];
export const BLOG_CONTENT_DOMAIN_CONTRACT = {
tables: [
'blog_post',
'blog_taxonomy',
'blog_term',
'blog_post_term',
'blog_theme_profile',
'blog_import_job',
],
publicArticleList: {
route: 'GET /blog/article/public/list',
sourceTable: 'blog_post',
statusField: 'status',
publishTimeField: 'publish_time',
},
publicArticleDetail: {
route: 'GET /blog/article/public/detail',
sourceTable: 'blog_post',
lookupFields: ['id', 'slug'],
},
termRelation: {
relationTable: 'blog_post_term',
postKey: 'post_id',
termKey: 'term_id',
taxonomyTables: ['blog_taxonomy', 'blog_term'],
},
themeProfile: {
table: 'blog_theme_profile',
profileKey: 'profile_key',
configField: 'config_json',
enabledField: 'enabled',
},
importJob: {
table: 'blog_import_job',
sourceField: 'source_key',
statusField: 'status',
summaryField: 'summary_json',
},
} as const;
@Module({
imports: [BlogModule],
exports: [BlogModule],
})
export class BlogContentModule {}

View File

@ -0,0 +1,74 @@
export const QQBOT_CORE_DOMAIN_CONTRACT = {
tables: [
'qqbot_account',
'qqbot_connection_session',
'qqbot_capability_binding',
'qqbot_permission_policy',
'qqbot_command',
'qqbot_command_alias',
'qqbot_rule',
'qqbot_conversation',
'qqbot_message',
'qqbot_send_task',
'qqbot_send_log',
'qqbot_dedupe_event',
],
status: {
accountTable: 'qqbot_account',
oneBotField: 'onebot_status',
containerField: 'container_status',
webuiField: 'webui_status',
qqLoginField: 'qq_login_status',
lastErrorField: 'last_error',
connectionSessionTable: 'qqbot_connection_session',
sessionStatusField: 'status',
closeReasonField: 'close_reason',
},
command: {
commandTable: 'qqbot_command',
commandIdField: 'id',
commandKeyField: 'command_key',
operationKeyField: 'operation_key',
pluginKeyField: 'plugin_key',
enabledField: 'enabled',
cooldownField: 'cooldown_seconds',
aliasTable: 'qqbot_command_alias',
aliasCommandField: 'command_id',
aliasTextField: 'alias_text',
accountBindingTable: 'qqbot_capability_binding',
accountBindingCommandField: 'capability_key',
parserValidation: {
route: 'POST /qqbot/command/test',
commandIdField: 'commandId',
fullCommandTextField: 'text',
},
},
permission: {
policyTable: 'qqbot_permission_policy',
policyKeyField: 'policy_key',
scopeFields: ['scope_type', 'scope_value'],
effectField: 'effect',
ruleTable: 'qqbot_rule',
ruleCommandField: 'command_id',
matcherField: 'matcher_json',
actionField: 'action_json',
enabledField: 'enabled',
},
messageSend: {
conversationTable: 'qqbot_conversation',
messageTable: 'qqbot_message',
sendQueueTable: 'qqbot_send_task',
queueTaskKeyField: 'task_key',
queueStatusField: 'status',
queuePayloadField: 'payload_json',
queueReservedAtField: 'reserved_at',
queueSentAtField: 'sent_at',
sendLogTable: 'qqbot_send_log',
sendLogStatusField: 'status',
sendLogSafeSummaryField: 'safe_summary',
dedupeTable: 'qqbot_dedupe_event',
dedupeKeyField: 'dedupe_key',
dedupeExpiresAtField: 'expires_at',
rateLimitProvider: 'QqbotRateLimitService',
},
} as const;

View File

@ -0,0 +1,151 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminAuthGuardModule } from '@/admin/auth/admin-auth-guard.module';
import { DictModule } from '@/admin/dict/dict.module';
import { QqbotAccountAbility } from '@/qqbot/account/qqbot-account-ability.entity';
import { QqbotAccountController } from '@/qqbot/account/qqbot-account.controller';
import { QqbotAccount } from '@/qqbot/account/qqbot-account.entity';
import { QqbotAccountService } from '@/qqbot/account/qqbot-account.service';
import { QqbotNapcatLoginService } from '@/qqbot/account/qqbot-napcat-login.service';
import { QqbotNapcatWatchdogService } from '@/qqbot/account/qqbot-napcat-watchdog.service';
import { QqbotCommandController } from '@/qqbot/command/qqbot-command.controller';
import { QqbotCommand } from '@/qqbot/command/qqbot-command.entity';
import { QqbotCommandEngineService } from '@/qqbot/command/qqbot-command-engine.service';
import { QqbotCommandLog } from '@/qqbot/command/qqbot-command-log.entity';
import { QqbotCommandParserService } from '@/qqbot/command/qqbot-command-parser.service';
import { QqbotCommandService } from '@/qqbot/command/qqbot-command.service';
import { QqbotReplyTemplateService } from '@/qqbot/command/qqbot-reply-template.service';
import { QqbotReverseWsService } from '@/qqbot/connection/qqbot-reverse-ws.service';
import { QqbotConfig } from '@/qqbot/config/qqbot-config.entity';
import { QqbotConfigService } from '@/qqbot/config/qqbot-config.service';
import { QqbotDashboardController } from '@/qqbot/dashboard/qqbot-dashboard.controller';
import { QqbotDashboardService } from '@/qqbot/dashboard/qqbot-dashboard.service';
import { QqbotDedupe } from '@/qqbot/dedupe/qqbot-dedupe.entity';
import { QqbotDedupeService } from '@/qqbot/dedupe/qqbot-dedupe.service';
import { QqbotEventService } from '@/qqbot/event/qqbot-event.service';
import { QqbotPluginHttpClientService } from '@/modules/qqbot/plugin-platform/sdk';
import {
NapcatDeviceIdentity,
NapcatDeviceIdentityService,
} from '@/modules/qqbot/napcat';
import { QqbotConversation } from '@/qqbot/message/qqbot-conversation.entity';
import { QqbotMessageController } from '@/qqbot/message/qqbot-message.controller';
import { QqbotMessage } from '@/qqbot/message/qqbot-message.entity';
import { QqbotMessageService } from '@/qqbot/message/qqbot-message.service';
import { QqbotBusService } from '@/qqbot/mqtt/qqbot-bus.service';
import { QqbotAccountNapcat } from '@/qqbot/napcat/qqbot-account-napcat.entity';
import { QqbotNapcatContainer } from '@/qqbot/napcat/qqbot-napcat-container.entity';
import { QqbotNapcatContainerService } from '@/qqbot/napcat/qqbot-napcat-container.service';
import { QqbotAllowlist } from '@/qqbot/permission/qqbot-allowlist.entity';
import { QqbotBlocklist } from '@/qqbot/permission/qqbot-blocklist.entity';
import { QqbotPermissionController } from '@/qqbot/permission/qqbot-permission.controller';
import { QqbotPermissionService } from '@/qqbot/permission/qqbot-permission.service';
import { QqbotEventPluginRegistryService } from '@/qqbot/plugin/qqbot-event-plugin-registry.service';
import { QqbotPluginController } from '@/qqbot/plugin/qqbot-plugin.controller';
import { QqbotPluginRegistryService } from '@/qqbot/plugin/qqbot-plugin-registry.service';
import { QqbotBangDreamClientService } from '@/modules/qqbot/plugins/bangDream/application/bangdream-client.service';
import { TsuguApplicationService } from '@/modules/qqbot/plugins/bangDream/application/bangdream-application.service';
import { QqbotBangDreamRendererService } from '@/modules/qqbot/plugins/bangDream/application/bangdream-renderer.facade';
import { QqbotBangDreamPluginService } from '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.plugin';
import { QqbotFf14ClientService } from '@/modules/qqbot/plugins/ff14Market/qqbot-ff14-client.service';
import { QqbotFf14MarketPluginService } from '@/modules/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin';
import { QqbotFflogsClientService } from '@/modules/qqbot/plugins/fflogs/qqbot-fflogs-client.service';
import { QqbotFflogsPluginService } from '@/modules/qqbot/plugins/fflogs/qqbot-fflogs.plugin';
import { QqbotRepeaterPluginService } from '@/modules/qqbot/plugins/repeater/qqbot-repeater.plugin';
import { QqbotRuleController } from '@/qqbot/rule/qqbot-rule.controller';
import { QqbotRule } from '@/qqbot/rule/qqbot-rule.entity';
import { QqbotRuleEngineService } from '@/qqbot/rule/qqbot-rule-engine.service';
import { QqbotRuleService } from '@/qqbot/rule/qqbot-rule.service';
import { QqbotRateLimitService } from '@/qqbot/send/qqbot-rate-limit.service';
import { QqbotSendController } from '@/qqbot/send/qqbot-send.controller';
import { QqbotSendLog } from '@/qqbot/send/qqbot-send-log.entity';
import { QqbotSendService } from '@/qqbot/send/qqbot-send.service';
export { QQBOT_CORE_DOMAIN_CONTRACT } from './qqbot-core.contract';
export const QQBOT_CORE_ENTITIES = [
QqbotAccount,
QqbotAccountAbility,
QqbotAllowlist,
QqbotBlocklist,
QqbotCommand,
QqbotCommandLog,
QqbotConfig,
QqbotConversation,
QqbotDedupe,
QqbotMessage,
NapcatDeviceIdentity,
QqbotAccountNapcat,
QqbotNapcatContainer,
QqbotRule,
QqbotSendLog,
];
export const QQBOT_CORE_CONTROLLERS = [
QqbotAccountController,
QqbotCommandController,
QqbotDashboardController,
QqbotMessageController,
QqbotPermissionController,
QqbotPluginController,
QqbotRuleController,
QqbotSendController,
];
export const QQBOT_CORE_PROVIDERS = [
QqbotAccountService,
QqbotBusService,
QqbotCommandEngineService,
QqbotCommandParserService,
QqbotCommandService,
QqbotConfigService,
QqbotDashboardService,
QqbotDedupeService,
QqbotEventService,
QqbotPluginHttpClientService,
QqbotBangDreamClientService,
QqbotBangDreamPluginService,
QqbotBangDreamRendererService,
TsuguApplicationService,
QqbotFf14ClientService,
QqbotFf14MarketPluginService,
QqbotFflogsClientService,
QqbotFflogsPluginService,
QqbotMessageService,
NapcatDeviceIdentityService,
QqbotNapcatLoginService,
QqbotNapcatWatchdogService,
QqbotNapcatContainerService,
QqbotPermissionService,
QqbotEventPluginRegistryService,
QqbotPluginRegistryService,
QqbotRepeaterPluginService,
QqbotRateLimitService,
QqbotReplyTemplateService,
QqbotReverseWsService,
QqbotRuleEngineService,
QqbotRuleService,
QqbotSendService,
];
export const QQBOT_CORE_EXPORTS = [
QqbotAccountService,
NapcatDeviceIdentityService,
QqbotNapcatLoginService,
QqbotNapcatContainerService,
QqbotReverseWsService,
];
@Module({
imports: [
ConfigModule,
AdminAuthGuardModule,
DictModule,
TypeOrmModule.forFeature(QQBOT_CORE_ENTITIES),
],
controllers: QQBOT_CORE_CONTROLLERS,
providers: QQBOT_CORE_PROVIDERS,
exports: QQBOT_CORE_EXPORTS,
})
export class QqbotCoreModule {}

View File

@ -0,0 +1,30 @@
import type { NapcatDeviceIdentity } from '../device/napcat-device-identity.entity';
export type NapcatDockerDeviceOptions = {
dataDir: string;
deviceEnvPath: string;
hostname: string;
machineIdPath: string;
macAddress: string;
runFlags: string[];
};
export function toNapcatDockerDeviceOptions(
identity: Pick<
NapcatDeviceIdentity,
'dataDir' | 'hostname' | 'machineIdPath' | 'macAddress'
>,
): NapcatDockerDeviceOptions {
return {
dataDir: identity.dataDir,
deviceEnvPath: `${identity.dataDir}/device.env`,
hostname: identity.hostname,
machineIdPath: identity.machineIdPath,
macAddress: identity.macAddress,
runFlags: [
'--hostname "$NAPCAT_HOSTNAME"',
'--mac-address "$NAPCAT_MAC_ADDRESS"',
'-v "$MACHINE_ID_PATH:/etc/machine-id:ro"',
],
};
}

View File

@ -0,0 +1,56 @@
import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm';
import {
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtUpdateDateColumn,
} from '@/common';
export type NapcatDeviceVerificationStatus =
| 'pending'
| 'trusted'
| 'unknown'
| 'unverified';
@Entity('napcat_device_identity')
@Index('uk_napcat_device_identity_account', ['accountId'], { unique: true })
@Index('idx_napcat_device_identity_container', ['containerId'])
export class NapcatDeviceIdentity {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ name: 'account_id', type: 'bigint' })
accountId: string;
@Column({ default: null, name: 'container_id', nullable: true, type: 'bigint' })
containerId: null | string;
@Column({ length: 512, name: 'data_dir' })
dataDir: string;
@Column({ length: 128 })
hostname: string;
@Column({ length: 512, name: 'machine_id_path' })
machineIdPath: string;
@Column({ length: 64, name: 'mac_address' })
macAddress: string;
@Column({ length: 32, name: 'verification_status' })
verificationStatus: NapcatDeviceVerificationStatus;
@Column({ default: null, name: 'last_login_evidence', nullable: true, type: 'json' })
lastLoginEvidence: null | Record<string, unknown>;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}

View File

@ -0,0 +1,101 @@
import { createHash } from 'crypto';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ensureSnowflakeId } from '@/common';
import { NapcatDeviceIdentity } from './napcat-device-identity.entity';
type ResolveNapcatDeviceIdentityInput = {
accountId: string;
containerId?: string;
selfId?: string;
};
@Injectable()
export class NapcatDeviceIdentityService {
constructor(
@InjectRepository(NapcatDeviceIdentity)
private readonly identityRepository: Repository<NapcatDeviceIdentity>,
private readonly configService: ConfigService,
) {}
async resolveForAccount(input: ResolveNapcatDeviceIdentityInput) {
const accountId = `${input.accountId}`.trim();
const existing = await this.identityRepository.findOne({
where: { accountId },
});
if (existing) {
const containerId = input.containerId || null;
if (containerId && existing.containerId !== containerId) {
await this.identityRepository.update(
{ id: existing.id },
{ containerId },
);
existing.containerId = containerId;
}
return existing;
}
const containerName = this.buildContainerName(input.selfId || accountId);
const dataDir = `${this.getRootDir()}/${containerName}`;
const identity = this.identityRepository.create({
accountId,
containerId: input.containerId || null,
dataDir,
hostname: this.buildHostname(containerName),
lastLoginEvidence: null,
macAddress: this.buildMacAddress(accountId, containerName),
machineIdPath: `${dataDir}/machine-id`,
verificationStatus: 'pending',
});
ensureSnowflakeId(identity);
return this.identityRepository.save(identity);
}
private buildContainerName(seed: string) {
const prefix = this.getConfig(
'QQBOT_NAPCAT_CONTAINER_PREFIX',
'kt-qqbot-napcat',
);
const suffix = `${seed || 'unknown'}`
.replace(/[^a-zA-Z0-9_.-]/g, '-')
.toLowerCase();
return `${prefix}-${suffix}`.replace(/-+/g, '-').slice(0, 120);
}
private buildHostname(containerName: string) {
const normalized = containerName
.replace(/[^a-zA-Z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
if (normalized.length <= 63) return normalized;
const hash = createHash('sha256').update(containerName).digest('hex');
return `${normalized.slice(0, 50)}-${hash.slice(0, 12)}`.slice(0, 63);
}
private buildMacAddress(accountId: string, containerName: string) {
const hash = createHash('sha256')
.update(`${accountId}:${containerName}`)
.digest('hex');
return ['02', '42', hash.slice(0, 2), hash.slice(2, 4), hash.slice(4, 6), hash.slice(6, 8)].join(
':',
);
}
private getRootDir() {
return (
this.getConfig(
'QQBOT_NAPCAT_ROOT',
'/vol1/docker/kt-qqbot/napcat-instances',
)
).replace(/[\\/]+$/, '');
}
private getConfig(key: string, defaultValue = '') {
return `${this.configService.get<string>(key) || defaultValue}`.trim();
}
}

View File

@ -0,0 +1,6 @@
export * from './container/napcat-docker-device-options';
export * from './device/napcat-device-identity.entity';
export * from './device/napcat-device-identity.service';
export * from './integration/napcat-login-api.client';
export * from './login/napcat-login-state-machine';
export * from './persistence';

View File

@ -0,0 +1,109 @@
export type NewDeviceQrStatus =
| 'confirming'
| 'expired'
| 'failed'
| 'qr-pending'
| 'scanned'
| 'verified';
export type NewDeviceQrCode = {
pullQrCodeSig?: string;
qrcodeUrl: string;
sessionId: string;
status: 'qr-pending';
};
export type NewDeviceQrPollResult = {
message?: string;
sessionId: string;
status: Exclude<NewDeviceQrStatus, 'verified'>;
};
export type NewDeviceLoginResult = {
message?: string;
sessionId: string;
status: 'failed' | 'verified';
success: boolean;
};
export type NapcatLoginApiTransport = {
post(path: string, body: Record<string, unknown>): Promise<unknown>;
};
export class NapcatLoginApiClient {
constructor(private readonly transport: NapcatLoginApiTransport) {}
async getNewDeviceQRCode(sessionId: string): Promise<NewDeviceQrCode> {
const data = (await this.transport.post(
'/api/QQLogin/GetNewDeviceQRCode',
{ sessionId },
)) as Record<string, unknown>;
const qrcodeUrl = this.pickString(
data.qrcodeUrl,
data.qrcodeurl,
data.qrcode,
data.url,
);
if (!qrcodeUrl) {
throw new Error('NapCat 未返回新设备验证二维码');
}
return {
pullQrCodeSig: this.pickString(data.newDevicePullQrCodeSig, data.sig),
qrcodeUrl,
sessionId,
status: 'qr-pending',
};
}
async pollNewDeviceQR(sessionId: string): Promise<NewDeviceQrPollResult> {
const data = (await this.transport.post(
'/api/QQLogin/PollNewDeviceQR',
{ sessionId },
)) as Record<string, unknown>;
const status = this.normalizePollStatus(
this.pickString(data.status, data.state, data.result),
);
return {
message: this.pickString(data.message, data.reason) || undefined,
sessionId,
status,
};
}
async newDeviceLogin(sessionId: string): Promise<NewDeviceLoginResult> {
const data = (await this.transport.post(
'/api/QQLogin/NewDeviceLogin',
{ sessionId },
)) as Record<string, unknown>;
const success = data.success !== false;
return {
message: this.pickString(data.message, data.reason) || undefined,
sessionId,
status: success ? 'verified' : 'failed',
success,
};
}
private normalizePollStatus(status: string): NewDeviceQrPollResult['status'] {
const normalized = status.toLowerCase().replace(/[_\s-]+/g, '');
if (['scan', 'scanned'].includes(normalized)) return 'scanned';
if (['confirm', 'confirming'].includes(normalized)) return 'confirming';
if (['expire', 'expired', 'timeout'].includes(normalized)) return 'expired';
if (['fail', 'failed', 'error', 'denied'].includes(normalized)) {
return 'failed';
}
return 'qr-pending';
}
private pickString(...values: unknown[]) {
for (const value of values) {
if (typeof value !== 'string') continue;
const trimmed = value.trim();
if (trimmed) return trimmed;
}
return '';
}
}

View File

@ -0,0 +1,277 @@
export type NapcatLoginSessionStatus = 'failure' | 'pending' | 'success';
export type NapcatLoginStage =
| 'captcha'
| 'cleanup-failed'
| 'failure'
| 'manual-qr'
| 'new-device'
| 'password-login'
| 'quick-login'
| 'success';
export type NapcatLoginChallenge =
| {
reason?: string;
status: 'failed' | 'pending' | 'submitted';
type: 'captcha';
url: string;
}
| {
pullQrCodeSig?: string;
qrcodeUrl?: string;
reason?: string;
status: 'confirming' | 'expired' | 'failed' | 'qr-pending' | 'scanned';
type: 'new-device';
};
export type NapcatLoginSessionState = {
accountId: string;
challenge?: NapcatLoginChallenge;
hasHistoricalSession: boolean;
hasSavedPassword: boolean;
progressMessage: string;
selfId?: string;
sessionId: string;
stage: NapcatLoginStage;
status: NapcatLoginSessionStatus;
};
export type CreateNapcatLoginSessionInput = {
accountId: string;
hasHistoricalSession: boolean;
hasSavedPassword: boolean;
sessionId: string;
};
export type NapcatLoginStateEvent =
| { type: 'captcha-still-required' }
| { type: 'captcha-submitted' }
| { newDevicePullQrCodeSig?: string; type: 'captcha-new-device-required' }
| { reason?: string; type: 'login-failed' }
| { selfId?: string; type: 'login-success' }
| { type: 'manual-qr-required' }
| { captchaUrl: string; type: 'password-login-captcha-required' }
| { type: 'password-login-failed' }
| { type: 'quick-login-failed' }
| { qrcodeUrl?: string; type: 'new-device-qr-ready' }
| { type: 'new-device-scanned' }
| { type: 'new-device-confirming' }
| { type: 'new-device-poll-pending' }
| { type: 'new-device-verified' }
| { reason?: string; type: 'new-device-expired' }
| { reason?: string; type: 'new-device-failed' }
| { reason?: string; type: 'runtime-cleanup-failed' };
export const NAPCAT_LOGIN_PROGRESS_MESSAGES = {
captchaNeeded: '需要验证码',
captchaSubmitted: '验证码已提交,等待确认',
cleanupFailed: '运行态清理失败',
failed: '登录失败',
manualQr: '正在生成手动二维码',
newDeviceConfirming: '新设备确认中',
newDeviceNeeded: '需要新设备验证二维码',
newDeviceQrPending: '新设备二维码待扫码',
newDeviceScanned: '新设备二维码已扫码',
newDeviceVerified: '新设备验证成功,继续登录',
passwordLogin: '正在密码登录',
quickLogin: '正在快速登录',
quickToPassword: '快速登录失败,进入密码登录',
success: '登录成功',
} as const;
export function createNapcatLoginSession(
input: CreateNapcatLoginSessionInput,
): NapcatLoginSessionState {
const stage = input.hasHistoricalSession ? 'quick-login' : 'password-login';
return {
accountId: input.accountId,
hasHistoricalSession: input.hasHistoricalSession,
hasSavedPassword: input.hasSavedPassword,
progressMessage: input.hasHistoricalSession
? NAPCAT_LOGIN_PROGRESS_MESSAGES.quickLogin
: NAPCAT_LOGIN_PROGRESS_MESSAGES.passwordLogin,
sessionId: input.sessionId,
stage,
status: 'pending',
};
}
export class NapcatLoginStateMachine {
advance(
session: NapcatLoginSessionState,
event: NapcatLoginStateEvent,
): NapcatLoginSessionState {
if (session.status === 'failure') return session;
switch (event.type) {
case 'quick-login-failed':
return {
...session,
progressMessage: session.hasSavedPassword
? NAPCAT_LOGIN_PROGRESS_MESSAGES.quickToPassword
: NAPCAT_LOGIN_PROGRESS_MESSAGES.manualQr,
stage: session.hasSavedPassword ? 'password-login' : 'manual-qr',
};
case 'password-login-failed':
case 'manual-qr-required':
return {
...session,
progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.manualQr,
stage: 'manual-qr',
};
case 'password-login-captcha-required':
return {
...session,
challenge: {
status: 'pending',
type: 'captcha',
url: event.captchaUrl,
},
progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.captchaNeeded,
stage: 'captcha',
};
case 'captcha-still-required':
return session.challenge?.type === 'captcha'
? {
...session,
challenge: {
...session.challenge,
status:
session.challenge.status === 'submitted'
? 'submitted'
: 'pending',
},
progressMessage:
session.challenge.status === 'submitted'
? NAPCAT_LOGIN_PROGRESS_MESSAGES.captchaSubmitted
: NAPCAT_LOGIN_PROGRESS_MESSAGES.captchaNeeded,
stage: 'captcha',
}
: session;
case 'captcha-submitted':
return session.challenge?.type === 'captcha'
? {
...session,
challenge: {
...session.challenge,
status: 'submitted',
},
progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.captchaSubmitted,
stage: 'captcha',
}
: session;
case 'captcha-new-device-required':
return {
...session,
challenge: {
pullQrCodeSig: event.newDevicePullQrCodeSig,
status: 'qr-pending',
type: 'new-device',
},
progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.newDeviceNeeded,
stage: 'new-device',
};
case 'new-device-qr-ready':
case 'new-device-poll-pending':
return this.updateNewDeviceChallenge(session, {
qrcodeUrl:
event.type === 'new-device-qr-ready'
? event.qrcodeUrl
: undefined,
status: 'qr-pending',
progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.newDeviceQrPending,
});
case 'new-device-scanned':
return this.updateNewDeviceChallenge(session, {
status: 'scanned',
progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.newDeviceScanned,
});
case 'new-device-confirming':
return this.updateNewDeviceChallenge(session, {
status: 'confirming',
progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.newDeviceConfirming,
});
case 'new-device-expired':
return this.updateNewDeviceChallenge(session, {
reason: event.reason,
status: 'expired',
progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.newDeviceQrPending,
});
case 'new-device-failed':
return this.updateNewDeviceChallenge(session, {
reason: event.reason,
status: 'failed',
progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.failed,
});
case 'new-device-verified':
return {
...session,
challenge: undefined,
progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.newDeviceVerified,
stage: 'password-login',
};
case 'runtime-cleanup-failed':
return {
...session,
progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.cleanupFailed,
stage: 'cleanup-failed',
status: 'failure',
};
case 'login-success':
return {
...session,
progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.success,
selfId: event.selfId,
stage: 'success',
status: 'success',
};
case 'login-failed':
return {
...session,
progressMessage: event.reason || NAPCAT_LOGIN_PROGRESS_MESSAGES.failed,
stage: 'failure',
status: 'failure',
};
}
}
private updateNewDeviceChallenge(
session: NapcatLoginSessionState,
patch: {
progressMessage: string;
qrcodeUrl?: string;
reason?: string;
status: Extract<NapcatLoginChallenge, { type: 'new-device' }>['status'];
},
): NapcatLoginSessionState {
if (session.challenge?.type !== 'new-device') return session;
return {
...session,
challenge: {
...session.challenge,
qrcodeUrl: patch.qrcodeUrl || session.challenge.qrcodeUrl,
reason: patch.reason || session.challenge.reason,
status: patch.status,
},
progressMessage: patch.progressMessage,
stage: 'new-device',
};
}
}

View File

@ -0,0 +1,14 @@
import { NapcatDeviceIdentity } from './device/napcat-device-identity.entity';
export const NAPCAT_RUNTIME_DOMAIN_CONTRACT = {
tables: [
'napcat_container',
'napcat_device_identity',
'napcat_account_binding',
'napcat_login_session',
'napcat_login_challenge',
'napcat_runtime_cleanup',
],
} as const;
export const NAPCAT_RUNTIME_ENTITIES = [NapcatDeviceIdentity];

View File

@ -0,0 +1,2 @@
export * from './manifest.parser';
export * from './manifest.types';

View File

@ -0,0 +1,395 @@
import * as path from 'path';
import {
QQBOT_PLUGIN_ALLOWED_PERMISSIONS,
QQBOT_PLUGIN_WORKER_TYPES,
type QqbotPluginAssetManifest,
type QqbotPluginEventManifest,
type QqbotPluginManifest,
type QqbotPluginManifestParseOptions,
type QqbotPluginManifestValidationIssue,
type QqbotPluginMigrationManifest,
type QqbotPluginOperationManifest,
type QqbotPluginPermission,
type QqbotPluginRuntimeManifest,
type QqbotPluginWorkerType,
} from './manifest.types';
const pluginKeyPattern = /^[a-z][a-z0-9-]{2,63}$/;
const semanticVersionPattern = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
const capabilityKeyPattern = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/;
const windowsAbsolutePathPattern = /^[a-zA-Z]:[\\/]/;
const allowedPermissionSet = new Set<string>(QQBOT_PLUGIN_ALLOWED_PERMISSIONS);
const allowedWorkerTypeSet = new Set<string>(QQBOT_PLUGIN_WORKER_TYPES);
export class QqbotPluginManifestValidationError extends Error {
constructor(readonly issues: QqbotPluginManifestValidationIssue[]) {
super(
`QQBot plugin manifest validation failed: ${issues
.map((issue) => `${issue.path}:${issue.code}`)
.join(', ')}`,
);
this.name = 'QqbotPluginManifestValidationError';
}
}
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
return !!value && typeof value === 'object' && !Array.isArray(value);
};
const getString = (
source: Record<string, unknown>,
key: string,
): string | undefined => {
const value = source[key];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
};
const getStringArray = (
source: Record<string, unknown>,
key: string,
): string[] => {
const value = source[key];
if (!Array.isArray(value)) return [];
return value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter(Boolean);
};
const getNumber = (
source: Record<string, unknown>,
key: string,
): number | undefined => {
const value = source[key];
return typeof value === 'number' && Number.isFinite(value)
? value
: undefined;
};
const pushIssue = (
issues: QqbotPluginManifestValidationIssue[],
code: string,
pathName: string,
message: string,
) => {
issues.push({ code, message, path: pathName });
};
const normalizePackagePath = (
value: unknown,
pathName: string,
issues: QqbotPluginManifestValidationIssue[],
): string => {
if (typeof value !== 'string' || !value.trim()) {
pushIssue(issues, 'REQUIRED_PATH', pathName, 'Package path is required.');
return '';
}
const candidate = value.trim().replace(/\\/g, '/');
const normalized = path.posix.normalize(candidate);
const outsideRoot =
candidate.includes('\0') ||
normalized === '..' ||
normalized.startsWith('../') ||
normalized.startsWith('/') ||
path.posix.isAbsolute(candidate) ||
windowsAbsolutePathPattern.test(candidate) ||
normalized.split('/').includes('..');
if (outsideRoot) {
pushIssue(
issues,
'PATH_OUTSIDE_PLUGIN_ROOT',
pathName,
'Package paths must stay inside the plugin root.',
);
return candidate;
}
return normalized;
};
const normalizePermissions = (
value: unknown,
pathName: string,
issues: QqbotPluginManifestValidationIssue[],
): QqbotPluginPermission[] => {
if (!Array.isArray(value)) return [];
const result: QqbotPluginPermission[] = [];
value.forEach((item, index) => {
if (typeof item !== 'string' || !item.trim()) {
pushIssue(
issues,
'INVALID_PERMISSION',
`${pathName}[${index}]`,
'Permission must be a non-empty string.',
);
return;
}
const permission = item.trim();
if (!allowedPermissionSet.has(permission)) {
pushIssue(
issues,
'UNKNOWN_PERMISSION',
`${pathName}[${index}]`,
`Unknown permission: ${permission}.`,
);
return;
}
if (!result.includes(permission as QqbotPluginPermission)) {
result.push(permission as QqbotPluginPermission);
}
});
return result;
};
const requireSemver = (
value: string | undefined,
pathName: string,
issues: QqbotPluginManifestValidationIssue[],
) => {
if (!value || !semanticVersionPattern.test(value)) {
pushIssue(
issues,
'INVALID_SEMVER',
pathName,
'Version must use semantic version format.',
);
}
};
const requireKey = (
value: string | undefined,
pathName: string,
issues: QqbotPluginManifestValidationIssue[],
) => {
if (!value || !capabilityKeyPattern.test(value)) {
pushIssue(
issues,
'INVALID_CAPABILITY_KEY',
pathName,
'Capability key must be dot-separated lower-case segments.',
);
}
};
const parseRuntime = (
source: Record<string, unknown>,
issues: QqbotPluginManifestValidationIssue[],
): QqbotPluginRuntimeManifest => {
const runtime = isPlainObject(source.runtime) ? source.runtime : {};
const timeoutMs = getNumber(runtime, 'timeoutMs');
const memoryMb = getNumber(runtime, 'memoryMb');
const maxConcurrency = getNumber(runtime, 'maxConcurrency');
const workerType = getString(runtime, 'workerType');
if (!timeoutMs || !memoryMb || !maxConcurrency) {
pushIssue(
issues,
'MISSING_RUNTIME_BUDGET',
'runtime',
'Runtime must declare timeoutMs, memoryMb, and maxConcurrency.',
);
}
if (!workerType || !allowedWorkerTypeSet.has(workerType)) {
pushIssue(
issues,
'INVALID_WORKER_TYPE',
'runtime.workerType',
'Runtime workerType is not supported.',
);
}
return {
maxConcurrency: maxConcurrency || 1,
memoryMb: memoryMb || 128,
timeoutMs: timeoutMs || 1000,
workerType: (workerType || 'node-worker') as QqbotPluginWorkerType,
};
};
const parseOperations = (
source: Record<string, unknown>,
issues: QqbotPluginManifestValidationIssue[],
): QqbotPluginOperationManifest[] => {
const operations = Array.isArray(source.operations) ? source.operations : [];
const seenKeys = new Set<string>();
return operations.filter(isPlainObject).map((operation, index) => {
const pathPrefix = `operations[${index}]`;
const key = getString(operation, 'key') || '';
const timeoutMs = getNumber(operation, 'timeoutMs');
requireKey(key, `${pathPrefix}.key`, issues);
if (seenKeys.has(key)) {
pushIssue(
issues,
'DUPLICATE_OPERATION_KEY',
pathPrefix,
`Duplicate operation key: ${key}.`,
);
}
seenKeys.add(key);
if (!timeoutMs) {
pushIssue(
issues,
'MISSING_OPERATION_TIMEOUT',
`${pathPrefix}.timeoutMs`,
'Operation timeoutMs is required.',
);
}
return {
aliases: getStringArray(operation, 'aliases'),
description: getString(operation, 'description'),
handlerName: getString(operation, 'handlerName') || '',
inputSchema: isPlainObject(operation.inputSchema)
? operation.inputSchema
: undefined,
key,
name: getString(operation, 'name') || key,
outputSchema: isPlainObject(operation.outputSchema)
? operation.outputSchema
: undefined,
permissions: normalizePermissions(
operation.permissions,
`${pathPrefix}.permissions`,
issues,
),
timeoutMs: timeoutMs || 1000,
};
});
};
const parseEvents = (
source: Record<string, unknown>,
issues: QqbotPluginManifestValidationIssue[],
): QqbotPluginEventManifest[] => {
const events = Array.isArray(source.events) ? source.events : [];
const seenKeys = new Set<string>();
return events.filter(isPlainObject).map((event, index) => {
const pathPrefix = `events[${index}]`;
const key = getString(event, 'key') || '';
requireKey(key, `${pathPrefix}.key`, issues);
if (seenKeys.has(key)) {
pushIssue(
issues,
'DUPLICATE_EVENT_KEY',
pathPrefix,
`Duplicate event key: ${key}.`,
);
}
seenKeys.add(key);
return {
description: getString(event, 'description'),
eventName: getString(event, 'eventName') || '',
handlerName: getString(event, 'handlerName') || '',
key,
name: getString(event, 'name') || key,
};
});
};
const parseAssets = (
source: Record<string, unknown>,
issues: QqbotPluginManifestValidationIssue[],
): QqbotPluginAssetManifest[] => {
const assets = Array.isArray(source.assets) ? source.assets : [];
return assets.filter(isPlainObject).map((asset, index) => ({
contentHash: getString(asset, 'contentHash'),
key: getString(asset, 'key') || '',
path: normalizePackagePath(asset.path, `assets[${index}].path`, issues),
}));
};
const parseMigrations = (
source: Record<string, unknown>,
issues: QqbotPluginManifestValidationIssue[],
): QqbotPluginMigrationManifest[] => {
const migrations = Array.isArray(source.migrations) ? source.migrations : [];
return migrations.filter(isPlainObject).map((migration, index) => ({
path: normalizePackagePath(
migration.path,
`migrations[${index}].path`,
issues,
),
version: getString(migration, 'version') || '',
}));
};
export const parseQqbotPluginManifest = (
manifestLike: unknown,
options: QqbotPluginManifestParseOptions = {},
): QqbotPluginManifest => {
void options;
const issues: QqbotPluginManifestValidationIssue[] = [];
if (!isPlainObject(manifestLike)) {
throw new QqbotPluginManifestValidationError([
{
code: 'INVALID_MANIFEST',
message: 'Manifest must be a JSON object.',
path: '$',
},
]);
}
const pluginKey = getString(manifestLike, 'pluginKey') || '';
if (!pluginKeyPattern.test(pluginKey)) {
pushIssue(
issues,
'INVALID_PLUGIN_KEY',
'pluginKey',
'Plugin key must be lower-case kebab-case.',
);
}
const version = getString(manifestLike, 'version') || '';
const minApiSdkVersion = getString(manifestLike, 'minApiSdkVersion') || '';
requireSemver(version, 'version', issues);
requireSemver(minApiSdkVersion, 'minApiSdkVersion', issues);
const parsedManifest: QqbotPluginManifest = {
assets: parseAssets(manifestLike, issues),
author: getString(manifestLike, 'author'),
configSchema: isPlainObject(manifestLike.configSchema)
? manifestLike.configSchema
: {},
description: getString(manifestLike, 'description'),
entry: normalizePackagePath(manifestLike.entry, 'entry', issues),
events: parseEvents(manifestLike, issues),
homepage: getString(manifestLike, 'homepage'),
license: getString(manifestLike, 'license'),
migrations: parseMigrations(manifestLike, issues),
minApiSdkVersion,
name: getString(manifestLike, 'name') || pluginKey,
operations: parseOperations(manifestLike, issues),
permissions: normalizePermissions(
manifestLike.permissions,
'permissions',
issues,
),
pluginKey,
runtime: parseRuntime(manifestLike, issues),
version,
};
if (issues.length > 0) {
throw new QqbotPluginManifestValidationError(issues);
}
return parsedManifest;
};

View File

@ -0,0 +1,88 @@
export const QQBOT_PLUGIN_ALLOWED_PERMISSIONS = [
'asset.read',
'plugin.config.read',
'plugin.config.write',
'plugin.storage.read',
'plugin.storage.write',
'qqbot.command.read',
'qqbot.event.receive',
'qqbot.send',
'runtime.http',
] as const;
export const QQBOT_PLUGIN_WORKER_TYPES = [
'child-process',
'node-worker',
] as const;
export type QqbotPluginPermission =
(typeof QQBOT_PLUGIN_ALLOWED_PERMISSIONS)[number];
export type QqbotPluginWorkerType = (typeof QQBOT_PLUGIN_WORKER_TYPES)[number];
export type QqbotPluginRuntimeManifest = {
maxConcurrency: number;
memoryMb: number;
timeoutMs: number;
workerType: QqbotPluginWorkerType;
};
export type QqbotPluginOperationManifest = {
aliases: string[];
description?: string;
handlerName: string;
inputSchema?: Record<string, unknown>;
key: string;
name: string;
outputSchema?: Record<string, unknown>;
permissions: QqbotPluginPermission[];
timeoutMs: number;
};
export type QqbotPluginEventManifest = {
description?: string;
eventName: string;
handlerName: string;
key: string;
name: string;
};
export type QqbotPluginAssetManifest = {
contentHash?: string;
key: string;
path: string;
};
export type QqbotPluginMigrationManifest = {
path: string;
version: string;
};
export type QqbotPluginManifest = {
assets: QqbotPluginAssetManifest[];
author?: string;
configSchema: Record<string, unknown>;
description?: string;
entry: string;
events: QqbotPluginEventManifest[];
homepage?: string;
license?: string;
migrations: QqbotPluginMigrationManifest[];
minApiSdkVersion: string;
name: string;
operations: QqbotPluginOperationManifest[];
permissions: QqbotPluginPermission[];
pluginKey: string;
runtime: QqbotPluginRuntimeManifest;
version: string;
};
export type QqbotPluginManifestValidationIssue = {
code: string;
message: string;
path: string;
};
export type QqbotPluginManifestParseOptions = {
pluginRoot?: string;
};

View File

@ -0,0 +1,2 @@
export * from './plugin-platform.contract';
export * from './plugin-platform.entities';

View File

@ -0,0 +1,20 @@
export const QQBOT_PLUGIN_PLATFORM_DOMAIN_CONTRACT = {
routes: {
accountBindings: '/qqbot/plugin-platform/account-bindings',
installLocal: '/qqbot/plugin-platform/install-local',
installations: '/qqbot/plugin-platform/installations',
runtimeEvents: '/qqbot/plugin-platform/runtime-events',
validate: '/qqbot/plugin-platform/validate',
},
tables: [
'qqbot_plugin',
'qqbot_plugin_version',
'qqbot_plugin_installation',
'qqbot_plugin_operation',
'qqbot_plugin_event_handler',
'qqbot_plugin_account_binding',
'qqbot_plugin_config',
'qqbot_plugin_asset',
'qqbot_plugin_runtime_event',
],
} as const;

View File

@ -0,0 +1,303 @@
import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm';
import {
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtUpdateDateColumn,
} from '@/common';
export type QqbotPluginInstallStatus =
| 'disabled'
| 'enabled'
| 'failed'
| 'installed'
| 'uninstalled'
| 'uploaded'
| 'validated';
export type QqbotPluginRuntimeStatus =
| 'crashed'
| 'healthy'
| 'starting'
| 'stopped'
| 'unhealthy';
export type QqbotPluginRuntimeEventLevel = 'error' | 'info' | 'warn';
@Entity('qqbot_plugin')
@Index('uk_qqbot_plugin_key', ['pluginKey'], { unique: true })
export class QqbotPlugin {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ length: 128, name: 'plugin_key' })
pluginKey: string;
@Column({ length: 128, name: 'plugin_name' })
pluginName: string;
@Column({ name: 'description', nullable: true, type: 'text' })
description: null | string;
@Column({ length: 32 })
status: QqbotPluginInstallStatus;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}
@Entity('qqbot_plugin_version')
@Index('uk_qqbot_plugin_version', ['pluginId', 'version'], { unique: true })
export class QqbotPluginVersion {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ name: 'plugin_id', type: 'bigint' })
pluginId: string;
@Column({ length: 64 })
version: string;
@Column({ length: 128, name: 'package_hash' })
packageHash: string;
@Column({ name: 'manifest_json', type: 'simple-json' })
manifestJson: Record<string, unknown>;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}
@Entity('qqbot_plugin_installation')
@Index('idx_qqbot_plugin_installation_status', ['status'])
export class QqbotPluginInstallation {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ name: 'plugin_id', type: 'bigint' })
pluginId: string;
@Column({ name: 'version_id', type: 'bigint' })
versionId: string;
@Column({ length: 32 })
status: QqbotPluginInstallStatus;
@Column({ length: 32, name: 'runtime_status' })
runtimeStatus: QqbotPluginRuntimeStatus;
@Column({ length: 512, name: 'installed_path' })
installedPath: string;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}
@Entity('qqbot_plugin_operation')
@Index('uk_qqbot_plugin_operation', ['pluginId', 'operationKey'], {
unique: true,
})
export class QqbotPluginOperation {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ name: 'plugin_id', type: 'bigint' })
pluginId: string;
@Column({ length: 128, name: 'operation_key' })
operationKey: string;
@Column({ length: 128, name: 'operation_name' })
operationName: string;
@Column({ length: 128, name: 'handler_name' })
handlerName: string;
@Column({ default: true })
enabled: boolean;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}
@Entity('qqbot_plugin_event_handler')
@Index('uk_qqbot_plugin_event_handler', ['pluginId', 'eventKey'], {
unique: true,
})
export class QqbotPluginEventHandler {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ name: 'plugin_id', type: 'bigint' })
pluginId: string;
@Column({ length: 128, name: 'event_key' })
eventKey: string;
@Column({ length: 128, name: 'handler_name' })
handlerName: string;
@Column({ default: true })
enabled: boolean;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}
@Entity('qqbot_plugin_account_binding')
@Index('uk_qqbot_plugin_account_binding', ['pluginId', 'accountId'], {
unique: true,
})
export class QqbotPluginAccountBinding {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ name: 'plugin_id', type: 'bigint' })
pluginId: string;
@Column({ name: 'account_id', type: 'bigint' })
accountId: string;
@Column({ default: true })
enabled: boolean;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}
@Entity('qqbot_plugin_config')
@Index('uk_qqbot_plugin_config', ['pluginId', 'configKey'], { unique: true })
export class QqbotPluginConfig {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ name: 'plugin_id', type: 'bigint' })
pluginId: string;
@Column({ length: 128, name: 'config_key' })
configKey: string;
@Column({ name: 'config_value', nullable: true, type: 'simple-json' })
configValue: null | Record<string, unknown>;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}
@Entity('qqbot_plugin_asset')
@Index('uk_qqbot_plugin_asset', ['pluginId', 'assetKey'], { unique: true })
export class QqbotPluginAsset {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ name: 'plugin_id', type: 'bigint' })
pluginId: string;
@Column({ length: 255, name: 'asset_key' })
assetKey: string;
@Column({ length: 512, name: 'asset_path' })
assetPath: string;
@Column({ length: 128, name: 'content_hash' })
contentHash: string;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}
@Entity('qqbot_plugin_runtime_event')
@Index('idx_qqbot_plugin_runtime_event_plugin', ['pluginId'])
export class QqbotPluginRuntimeEvent {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ name: 'plugin_id', type: 'bigint' })
pluginId: string;
@Column({
default: null,
name: 'installation_id',
nullable: true,
type: 'bigint',
})
installationId: null | string;
@Column({ length: 64, name: 'event_type' })
eventType: string;
@Column({ length: 32 })
level: QqbotPluginRuntimeEventLevel;
@Column({ name: 'safe_summary', nullable: true, type: 'simple-json' })
safeSummary: null | Record<string, unknown>;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}
export const QQBOT_PLUGIN_PLATFORM_ENTITIES = [
QqbotPlugin,
QqbotPluginVersion,
QqbotPluginInstallation,
QqbotPluginOperation,
QqbotPluginEventHandler,
QqbotPluginAccountBinding,
QqbotPluginConfig,
QqbotPluginAsset,
QqbotPluginRuntimeEvent,
] as const;

View File

@ -0,0 +1,135 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
import { vbenSuccess } from '@/common';
import { QqbotPluginPlatformService } from './plugin-platform.service';
@ApiTags('QQBot - 插件平台')
@Controller('qqbot/plugin-platform')
@UseGuards(JwtAuthGuard)
export class QqbotPluginPlatformController {
constructor(private readonly service: QqbotPluginPlatformService) {}
@Get('installations')
@ApiOperation({ summary: '插件安装列表' })
async installations() {
return vbenSuccess(await this.service.listInstallations());
}
@Post('validate')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '校验插件 manifest' })
async validate(@Body() body: { manifest?: unknown }) {
return vbenSuccess(this.service.validateManifest(body));
}
@Post('upload')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '上传插件包' })
async upload(
@Body()
body: {
manifest?: unknown;
packageHash?: string;
packagePath?: string;
},
) {
return vbenSuccess(this.service.validateManifest(body));
}
@Post('install')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '安装插件包' })
async install(
@Body()
body: {
manifest?: unknown;
packageHash?: string;
packagePath?: string;
},
) {
return vbenSuccess(await this.service.installLocal(body));
}
@Post('install-local')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '本地安装插件包' })
async installLocal(
@Body()
body: {
manifest?: unknown;
packageHash?: string;
packagePath?: string;
},
) {
return vbenSuccess(await this.service.installLocal(body));
}
@Post('enable')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '启用插件' })
async enable(@Body() body: { id?: string }) {
return vbenSuccess(
await this.service.setInstallationStatus(body, 'enabled'),
);
}
@Post('disable')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '禁用插件' })
async disable(@Body() body: { id?: string }) {
return vbenSuccess(
await this.service.setInstallationStatus(body, 'disabled'),
);
}
@Post('upgrade')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '升级插件' })
async upgrade(@Body() body: { id?: string }) {
return vbenSuccess(
await this.service.setInstallationStatus(body, 'installed'),
);
}
@Post('uninstall')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '卸载插件' })
async uninstall(@Body() body: { id?: string }) {
return vbenSuccess(
await this.service.setInstallationStatus(body, 'uninstalled'),
);
}
@Post('config')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '更新插件配置' })
async config(
@Body() body: { configKey?: string; pluginId?: string; value?: unknown },
) {
return vbenSuccess(await this.service.updateConfig(body));
}
@Get('runtime-events')
@ApiOperation({ summary: '插件运行事件列表' })
@ApiQuery({ name: 'pluginId', required: false, type: String })
async runtimeEvents(@Query('pluginId') pluginId?: string) {
return vbenSuccess(await this.service.listRuntimeEvents(pluginId));
}
@Get('account-bindings')
@ApiOperation({ summary: '插件账号绑定列表' })
@ApiQuery({ name: 'pluginId', required: false, type: String })
async accountBindings(@Query('pluginId') pluginId?: string) {
return vbenSuccess(await this.service.listAccountBindings(pluginId));
}
}

View File

@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminAuthGuardModule } from '@/admin/auth/admin-auth-guard.module';
import { QqbotPluginPlatformController } from './plugin-platform.controller';
import { QqbotPluginPlatformService } from './plugin-platform.service';
import { QQBOT_PLUGIN_PLATFORM_ENTITIES } from './persistence';
import { QqbotPluginHttpClientService } from './sdk';
@Module({
controllers: [QqbotPluginPlatformController],
exports: [QqbotPluginHttpClientService, QqbotPluginPlatformService],
imports: [
AdminAuthGuardModule,
TypeOrmModule.forFeature([...QQBOT_PLUGIN_PLATFORM_ENTITIES]),
],
providers: [QqbotPluginHttpClientService, QqbotPluginPlatformService],
})
export class QqbotPluginPlatformModule {}

View File

@ -0,0 +1,168 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { throwVbenError } from '@/common';
import { parseQqbotPluginManifest, type QqbotPluginManifest } from './manifest';
import {
QqbotPlugin,
QqbotPluginAccountBinding,
QqbotPluginAsset,
QqbotPluginConfig,
QqbotPluginEventHandler,
QqbotPluginInstallation,
QqbotPluginOperation,
QqbotPluginRuntimeEvent,
QqbotPluginVersion,
type QqbotPluginInstallStatus,
} from './persistence';
type ValidateManifestBody = {
manifest?: unknown;
};
type InstallLocalBody = {
manifest?: unknown;
packageHash?: string;
packagePath?: string;
};
type InstallationActionBody = {
id?: string;
};
type UpdateConfigBody = {
configKey?: string;
pluginId?: string;
value?: unknown;
};
@Injectable()
export class QqbotPluginPlatformService {
constructor(
@InjectRepository(QqbotPlugin)
private readonly pluginRepository: Repository<QqbotPlugin>,
@InjectRepository(QqbotPluginVersion)
private readonly versionRepository: Repository<QqbotPluginVersion>,
@InjectRepository(QqbotPluginInstallation)
private readonly installationRepository: Repository<QqbotPluginInstallation>,
@InjectRepository(QqbotPluginOperation)
private readonly operationRepository: Repository<QqbotPluginOperation>,
@InjectRepository(QqbotPluginEventHandler)
private readonly eventHandlerRepository: Repository<QqbotPluginEventHandler>,
@InjectRepository(QqbotPluginAccountBinding)
private readonly accountBindingRepository: Repository<QqbotPluginAccountBinding>,
@InjectRepository(QqbotPluginConfig)
private readonly configRepository: Repository<QqbotPluginConfig>,
@InjectRepository(QqbotPluginAsset)
private readonly assetRepository: Repository<QqbotPluginAsset>,
@InjectRepository(QqbotPluginRuntimeEvent)
private readonly runtimeEventRepository: Repository<QqbotPluginRuntimeEvent>,
) {}
async listInstallations() {
return this.installationRepository.find();
}
validateManifest(body: ValidateManifestBody) {
return {
manifest: parseQqbotPluginManifest(body.manifest),
valid: true,
};
}
async installLocal(body: InstallLocalBody) {
const manifest = parseQqbotPluginManifest(body.manifest);
const plugin = await this.pluginRepository.save({
pluginKey: manifest.pluginKey,
pluginName: manifest.name,
description: manifest.description || null,
status: 'installed',
});
const version = await this.versionRepository.save({
manifestJson: manifest,
packageHash: body.packageHash || 'local-dev-package',
pluginId: plugin.id,
version: manifest.version,
});
await this.persistManifestCapabilities(plugin.id, manifest);
return this.installationRepository.save({
installedPath: body.packagePath || '',
pluginId: plugin.id,
runtimeStatus: 'stopped',
status: 'installed',
versionId: version.id,
});
}
async setInstallationStatus(
body: InstallationActionBody,
status: QqbotPluginInstallStatus,
) {
if (!body.id) throwVbenError('请选择插件安装记录');
await this.installationRepository.update({ id: body.id }, { status });
return {
id: body.id,
status,
};
}
async updateConfig(body: UpdateConfigBody) {
if (!body.pluginId || !body.configKey) {
throwVbenError('请选择插件和配置项');
}
return this.configRepository.save({
configKey: body.configKey,
configValue: body.value === undefined ? null : { value: body.value },
pluginId: body.pluginId,
});
}
async listRuntimeEvents(pluginId?: string) {
return this.runtimeEventRepository.find({
where: pluginId ? { pluginId } : undefined,
});
}
async listAccountBindings(pluginId?: string) {
return this.accountBindingRepository.find({
where: pluginId ? { pluginId } : undefined,
});
}
private async persistManifestCapabilities(
pluginId: string,
manifest: QqbotPluginManifest,
) {
await Promise.all([
...manifest.operations.map((operation) =>
this.operationRepository.save({
enabled: true,
handlerName: operation.handlerName,
operationKey: operation.key,
operationName: operation.name,
pluginId,
}),
),
...manifest.events.map((event) =>
this.eventHandlerRepository.save({
enabled: true,
eventKey: event.key,
handlerName: event.handlerName,
pluginId,
}),
),
...manifest.assets.map((asset) =>
this.assetRepository.save({
assetKey: asset.key,
assetPath: asset.path,
contentHash: asset.contentHash || '',
pluginId,
}),
),
]);
}
}

View File

@ -0,0 +1,2 @@
export * from './worker-runtime';
export * from './worker-runtime.types';

View File

@ -0,0 +1,200 @@
import {
type QqbotPluginEventRequest,
type QqbotPluginOperationRequest,
type QqbotPluginRuntimeErrorCode,
type QqbotPluginRuntimeEvent,
type QqbotPluginRuntimeStatus,
type QqbotPluginSafeInputSummary,
type QqbotPluginWorkerDriver,
type QqbotPluginWorkerRequest,
type QqbotPluginWorkerRequestType,
type QqbotPluginWorkerRuntimeOptions,
} from './worker-runtime.types';
export class QqbotPluginRuntimeError extends Error {
constructor(
readonly code: QqbotPluginRuntimeErrorCode,
readonly pluginKey: string,
message: string,
readonly safeSummary: Record<string, unknown> = {},
) {
super(message);
this.name = 'QqbotPluginRuntimeError';
}
}
const createCorrelationId = () => {
return `qqbot-plugin-${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
const summarizeInput = (
input: Record<string, unknown>,
): QqbotPluginSafeInputSummary => {
return {
fieldCount: Object.keys(input).length,
keys: Object.keys(input).sort(),
};
};
export class QqbotPluginWorkerRuntime {
private readonly runtimeEvents: QqbotPluginRuntimeEvent[] = [];
status: QqbotPluginRuntimeStatus = 'stopped';
constructor(
private readonly driver: QqbotPluginWorkerDriver,
private readonly options: QqbotPluginWorkerRuntimeOptions,
) {}
async load(manifest: unknown) {
const result = await this.request('load', {
manifest,
});
this.status = 'loaded';
return result;
}
async activate() {
const result = await this.request('activate');
this.status = 'active';
return result;
}
async executeOperation(request: QqbotPluginOperationRequest) {
return this.request(
'executeOperation',
{
input: request.input,
operationId: request.operationId,
operationKey: request.operationKey,
safeInputSummary: summarizeInput(request.input),
},
request.timeoutMs,
);
}
async handleEvent(request: QqbotPluginEventRequest) {
return this.request(
'handleEvent',
{
event: request.event,
eventKey: request.eventKey,
safeInputSummary: summarizeInput(request.event),
},
request.timeoutMs,
);
}
async health() {
return this.request('health');
}
async deactivate() {
const result = await this.request('deactivate');
this.status = 'stopped';
return result;
}
async dispose() {
try {
await this.request('dispose');
} finally {
await this.driver.dispose();
this.status = 'stopped';
}
}
listRuntimeEvents() {
return [...this.runtimeEvents];
}
private async request(
type: QqbotPluginWorkerRequestType,
payload: Partial<QqbotPluginWorkerRequest> = {},
timeoutMs = this.options.defaultTimeoutMs,
) {
const message: QqbotPluginWorkerRequest = {
correlationId: createCorrelationId(),
installationId: this.options.installationId,
pluginKey: this.options.pluginKey,
timeoutMs,
type,
...payload,
};
const requestPromise = Promise.resolve().then(() =>
this.driver.request(message),
);
requestPromise.catch(() => undefined);
try {
return await Promise.race([
requestPromise,
this.createTimeoutPromise(type, message, timeoutMs),
]);
} catch (error) {
if (error instanceof QqbotPluginRuntimeError) {
throw error;
}
this.status = 'failed';
const runtimeError = new QqbotPluginRuntimeError(
'PLUGIN_WORKER_CRASH',
this.options.pluginKey,
'QQBot plugin worker crashed.',
{
correlationId: message.correlationId,
message: error instanceof Error ? error.message : `${error}`,
operationId: message.operationId,
type,
},
);
this.recordRuntimeEvent('worker-crash', runtimeError.safeSummary);
throw runtimeError;
}
}
private createTimeoutPromise(
type: QqbotPluginWorkerRequestType,
message: QqbotPluginWorkerRequest,
timeoutMs: number,
) {
return new Promise<never>((_, reject) => {
const timer = setTimeout(async () => {
this.status = 'failed';
const error = new QqbotPluginRuntimeError(
'PLUGIN_WORKER_TIMEOUT',
this.options.pluginKey,
'QQBot plugin worker timed out.',
{
correlationId: message.correlationId,
operationId: message.operationId,
timeoutMs,
type,
},
);
this.recordRuntimeEvent('worker-timeout', error.safeSummary);
try {
await this.driver.dispose();
} catch {
// Timeout disposal is best-effort; the timeout error is the primary signal.
}
reject(error);
}, timeoutMs);
timer.unref?.();
});
}
private recordRuntimeEvent(
eventType: string,
safeSummary: Record<string, unknown>,
) {
this.runtimeEvents.push({
eventType,
level: 'error',
pluginKey: this.options.pluginKey,
safeSummary,
});
}
}

View File

@ -0,0 +1,69 @@
export type QqbotPluginWorkerRequestType =
| 'activate'
| 'deactivate'
| 'dispose'
| 'executeOperation'
| 'handleEvent'
| 'health'
| 'load';
export type QqbotPluginRuntimeStatus =
| 'active'
| 'failed'
| 'loaded'
| 'stopped';
export type QqbotPluginSafeInputSummary = {
fieldCount: number;
keys: string[];
};
export type QqbotPluginWorkerRequest = {
correlationId: string;
event?: unknown;
eventKey?: string;
installationId?: string;
manifest?: unknown;
operationId?: string;
operationKey?: string;
input?: unknown;
pluginKey: string;
safeInputSummary?: QqbotPluginSafeInputSummary;
timeoutMs: number;
type: QqbotPluginWorkerRequestType;
};
export type QqbotPluginWorkerDriver = {
dispose(): Promise<void>;
request(message: QqbotPluginWorkerRequest): Promise<unknown>;
};
export type QqbotPluginWorkerRuntimeOptions = {
defaultTimeoutMs: number;
installationId: string;
pluginKey: string;
};
export type QqbotPluginRuntimeEvent = {
eventType: string;
level: 'error' | 'info' | 'warn';
pluginKey: string;
safeSummary: Record<string, unknown>;
};
export type QqbotPluginRuntimeErrorCode =
| 'PLUGIN_WORKER_CRASH'
| 'PLUGIN_WORKER_TIMEOUT';
export type QqbotPluginOperationRequest = {
input: Record<string, unknown>;
operationId: string;
operationKey: string;
timeoutMs?: number;
};
export type QqbotPluginEventRequest = {
event: Record<string, unknown>;
eventKey: string;
timeoutMs?: number;
};

View File

@ -0,0 +1,2 @@
export * from './plugin-sdk';
export * from './plugin-http-client.service';

View File

@ -0,0 +1,81 @@
import { Injectable } from '@nestjs/common';
import * as http from 'node:http';
import * as https from 'node:https';
export type QqbotPluginHttpClientRequest = {
body?: Buffer | string;
context?: string;
failureMessage?: (statusCode: number) => string;
headers?: Record<string, string>;
invalidJsonMessage?: string;
method?: string;
timeoutMessage?: string;
timeoutMs?: number;
url: string | URL;
};
@Injectable()
export class QqbotPluginHttpClientService {
async requestJson<T>(input: QqbotPluginHttpClientRequest): Promise<T> {
const body = await this.requestText(input);
try {
return JSON.parse(body) as T;
} catch {
throw new Error(
input.invalidJsonMessage ||
`${input.context || '插件 HTTP 接口'}返回不是合法 JSON`,
);
}
}
requestText(input: QqbotPluginHttpClientRequest): Promise<string> {
const url = input.url instanceof URL ? input.url : new URL(input.url);
const method = input.method || 'GET';
const timeoutMs = input.timeoutMs || 8000;
const context = input.context || '插件 HTTP 接口';
return new Promise<string>((resolve, reject) => {
const client = url.protocol === 'http:' ? http : https;
const request = client.request(
url,
{
headers: {
Accept: 'application/json',
'User-Agent': 'kt-template-online-api/qqbot-plugin',
...(input.headers || {}),
},
method,
timeout: timeoutMs,
},
(response) => {
let responseBody = '';
response.setEncoding('utf8');
response.on('data', (chunk) => {
responseBody += chunk;
});
response.on('end', () => {
const statusCode = response.statusCode || 500;
if (statusCode >= 400) {
reject(
new Error(
input.failureMessage?.(statusCode) ||
`${context}请求失败:${statusCode}`,
),
);
return;
}
resolve(responseBody);
});
},
);
request.on('timeout', () => {
request.destroy(
new Error(input.timeoutMessage || `${context}请求超时`),
);
});
request.on('error', reject);
if (input.body) request.write(input.body);
request.end();
});
}
}

View File

@ -0,0 +1,53 @@
export type QqbotPluginSendQueueSdk = {
sendMessage(input: Record<string, unknown>): Promise<unknown>;
};
export type QqbotPluginConfigSdk = {
getConfig(key: string): Promise<unknown>;
setConfig(key: string, value: unknown): Promise<void>;
};
export type QqbotPluginStorageSdk = {
get(key: string): Promise<unknown>;
set(key: string, value: unknown): Promise<void>;
};
export type QqbotPluginHttpSdk = {
request(input: Record<string, unknown>): Promise<unknown>;
};
export type QqbotPluginAssetSdk = {
readAsset(assetKey: string): Promise<Buffer>;
};
export type QqbotPluginRuntimeEventSdk = {
emitRuntimeEvent(input: Record<string, unknown>): Promise<void>;
};
export type QqbotPluginSdkFactoryInput = {
assets: QqbotPluginAssetSdk;
config: QqbotPluginConfigSdk;
eventContext: Record<string, unknown>;
events: QqbotPluginRuntimeEventSdk;
http: QqbotPluginHttpSdk;
operationContext: Record<string, unknown>;
sendQueue: QqbotPluginSendQueueSdk;
storage: QqbotPluginStorageSdk;
};
export type QqbotPluginSdk = Readonly<QqbotPluginSdkFactoryInput>;
export const createQqbotPluginSdk = (
input: QqbotPluginSdkFactoryInput,
): QqbotPluginSdk => {
return Object.freeze({
assets: input.assets,
config: input.config,
eventContext: Object.freeze({ ...input.eventContext }),
events: input.events,
http: input.http,
operationContext: Object.freeze({ ...input.operationContext }),
sendQueue: input.sendQueue,
storage: input.storage,
});
};

View File

@ -4,15 +4,15 @@ import type {
QqbotBangDreamCommandInput,
QqbotBangDreamCommandOutput,
QqbotBangDreamOperationKey,
} from '@/qqbot/plugins/bangDream/qqbot-bangdream.types';
import { waitForMainDataReady } from '@/qqbot/plugins/bangDream/shared/main-data-store';
} from '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.types';
import { waitForMainDataReady } from '@/modules/qqbot/plugins/bangDream/shared/main-data-store';
import {
createTsuguLogHook,
TsuguHookRegistry,
} from '@/qqbot/plugins/bangDream/hook/hook-registry';
import { getBangDreamOperationDefinition } from '@/qqbot/plugins/bangDream/registry/operation-registry';
import { TsuguOperationPipeline } from '@/qqbot/plugins/bangDream/application/operation-pipeline';
import { QqbotBangDreamRendererService } from '@/qqbot/plugins/bangDream/application/bangdream-renderer.facade';
} from '@/modules/qqbot/plugins/bangDream/hook/hook-registry';
import { getBangDreamOperationDefinition } from '@/modules/qqbot/plugins/bangDream/registry/operation-registry';
import { TsuguOperationPipeline } from '@/modules/qqbot/plugins/bangDream/application/operation-pipeline';
import { QqbotBangDreamRendererService } from '@/modules/qqbot/plugins/bangDream/application/bangdream-renderer.facade';
@Injectable()
export class TsuguApplicationService implements OnApplicationBootstrap {

View File

@ -4,8 +4,8 @@ import type {
QqbotBangDreamOperationKey,
QqbotBangDreamSongSearchInput,
QqbotBangDreamSongSummary,
} from '@/qqbot/plugins/bangDream/qqbot-bangdream.types';
import { TsuguApplicationService } from '@/qqbot/plugins/bangDream/application/bangdream-application.service';
} from '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.types';
import { TsuguApplicationService } from '@/modules/qqbot/plugins/bangDream/application/bangdream-application.service';
@Injectable()
export class QqbotBangDreamClientService {

View File

@ -1,54 +1,59 @@
import { Injectable, Optional } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DictService } from '@/admin/dict/dict.service';
import { Card } from '@/qqbot/plugins/bangDream/card/card.model';
import { getPresentEvent } from '@/qqbot/plugins/bangDream/event/event.model';
import { Gacha, getPresentGachaList } from '@/qqbot/plugins/bangDream/gacha/gacha.model';
import { Card } from '@/modules/qqbot/plugins/bangDream/card/card.model';
import { getPresentEvent } from '@/modules/qqbot/plugins/bangDream/event/event.model';
import {
Gacha,
getPresentGachaList,
} from '@/modules/qqbot/plugins/bangDream/gacha/gacha.model';
import {
BANGDREAM_GACHA_DEFAULT_SPIN_COUNT,
isBirthdayGachaType,
} from '@/qqbot/plugins/bangDream/policy/gacha.policy';
import { Server } from '@/qqbot/plugins/bangDream/catalog/server.model';
import { Song } from '@/qqbot/plugins/bangDream/song/song.model';
import mainAPI, { waitForMainDataReady } from '@/qqbot/plugins/bangDream/shared/main-data-store';
} from '@/modules/qqbot/plugins/bangDream/policy/gacha.policy';
import { Server } from '@/modules/qqbot/plugins/bangDream/catalog/server.model';
import { Song } from '@/modules/qqbot/plugins/bangDream/song/song.model';
import mainAPI, {
waitForMainDataReady,
} from '@/modules/qqbot/plugins/bangDream/shared/main-data-store';
import {
fuzzySearch,
type FuzzySearchResult,
} from '@/qqbot/plugins/bangDream/search/fuzzy-search';
import { drawCardDetail } from '@/qqbot/plugins/bangDream/card/card-detail.renderer';
import { drawCardList } from '@/qqbot/plugins/bangDream/card/card-search.renderer';
import { drawCharacterDetail } from '@/qqbot/plugins/bangDream/character/character-detail.renderer';
import { drawCharacterList } from '@/qqbot/plugins/bangDream/character/character-search.renderer';
import { drawCutoffAll } from '@/qqbot/plugins/bangDream/cutoff/cutoff-all.renderer';
import { drawCutoffDetail } from '@/qqbot/plugins/bangDream/cutoff/cutoff-detail.renderer';
import { drawCutoffEventTop } from '@/qqbot/plugins/bangDream/cutoff/cutoff-event-top.renderer';
import { drawCutoffListOfRecentEvent } from '@/qqbot/plugins/bangDream/cutoff/cutoff-recent.renderer';
import { drawEventDetail } from '@/qqbot/plugins/bangDream/event/event-detail.renderer';
import { drawEventList } from '@/qqbot/plugins/bangDream/event/event-search.renderer';
import { drawEventStage } from '@/qqbot/plugins/bangDream/event/event-stage.renderer';
import { drawGachaDetail } from '@/qqbot/plugins/bangDream/gacha/gacha-detail.renderer';
import { drawRandomGacha } from '@/qqbot/plugins/bangDream/gacha/gacha-simulate.renderer';
import { drawPlayerDetail } from '@/qqbot/plugins/bangDream/player/player-detail.renderer';
import { drawSongChart } from '@/qqbot/plugins/bangDream/song/song-chart.renderer';
import { drawSongDetail } from '@/qqbot/plugins/bangDream/song/song-detail.renderer';
import { drawSongList } from '@/qqbot/plugins/bangDream/song/song-search.renderer';
import { drawSongMetaList } from '@/qqbot/plugins/bangDream/song/song-meta.renderer';
import { drawSongRandom } from '@/qqbot/plugins/bangDream/song/song-random.renderer';
} from '@/modules/qqbot/plugins/bangDream/search/fuzzy-search';
import { drawCardDetail } from '@/modules/qqbot/plugins/bangDream/card/card-detail.renderer';
import { drawCardList } from '@/modules/qqbot/plugins/bangDream/card/card-search.renderer';
import { drawCharacterDetail } from '@/modules/qqbot/plugins/bangDream/character/character-detail.renderer';
import { drawCharacterList } from '@/modules/qqbot/plugins/bangDream/character/character-search.renderer';
import { drawCutoffAll } from '@/modules/qqbot/plugins/bangDream/cutoff/cutoff-all.renderer';
import { drawCutoffDetail } from '@/modules/qqbot/plugins/bangDream/cutoff/cutoff-detail.renderer';
import { drawCutoffEventTop } from '@/modules/qqbot/plugins/bangDream/cutoff/cutoff-event-top.renderer';
import { drawCutoffListOfRecentEvent } from '@/modules/qqbot/plugins/bangDream/cutoff/cutoff-recent.renderer';
import { drawEventDetail } from '@/modules/qqbot/plugins/bangDream/event/event-detail.renderer';
import { drawEventList } from '@/modules/qqbot/plugins/bangDream/event/event-search.renderer';
import { drawEventStage } from '@/modules/qqbot/plugins/bangDream/event/event-stage.renderer';
import { drawGachaDetail } from '@/modules/qqbot/plugins/bangDream/gacha/gacha-detail.renderer';
import { drawRandomGacha } from '@/modules/qqbot/plugins/bangDream/gacha/gacha-simulate.renderer';
import { drawPlayerDetail } from '@/modules/qqbot/plugins/bangDream/player/player-detail.renderer';
import { drawSongChart } from '@/modules/qqbot/plugins/bangDream/song/song-chart.renderer';
import { drawSongDetail } from '@/modules/qqbot/plugins/bangDream/song/song-detail.renderer';
import { drawSongList } from '@/modules/qqbot/plugins/bangDream/song/song-search.renderer';
import { drawSongMetaList } from '@/modules/qqbot/plugins/bangDream/song/song-meta.renderer';
import { drawSongRandom } from '@/modules/qqbot/plugins/bangDream/song/song-random.renderer';
import {
BangDreamDictionaryLoader,
type BangDreamDictionaryItem,
} from '@/qqbot/plugins/bangDream/dictionary/dictionary-loader';
import type { QqbotBangDreamOperationHandlerName } from '@/qqbot/plugins/bangDream/registry/operation-registry';
} from '@/modules/qqbot/plugins/bangDream/dictionary/dictionary-loader';
import type { QqbotBangDreamOperationHandlerName } from '@/modules/qqbot/plugins/bangDream/registry/operation-registry';
import {
BANGDREAM_TSUGU_ENV_KEYS,
normalizeBangDreamBoolean,
splitBangDreamOptionList,
} from '@/qqbot/plugins/bangDream/config/runtime-options';
} from '@/modules/qqbot/plugins/bangDream/config/runtime-options';
import type {
QqbotBangDreamCommandInput,
QqbotBangDreamCommandOutput,
QqbotBangDreamOperationKey,
} from '@/qqbot/plugins/bangDream/qqbot-bangdream.types';
} from '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.types';
const SOURCE_NAME = 'Tsugu BangDream Bot 内置源码';

View File

@ -2,15 +2,15 @@ import type {
QqbotBangDreamCommandInput,
QqbotBangDreamCommandOutput,
QqbotBangDreamOperationKey,
} from '@/qqbot/plugins/bangDream/qqbot-bangdream.types';
} from '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.types';
import type {
QqbotBangDreamOperationHandlerName,
TsuguOperationDefinition,
} from '@/qqbot/plugins/bangDream/registry/operation-registry';
} from '@/modules/qqbot/plugins/bangDream/registry/operation-registry';
import {
createTsuguHookContext,
type TsuguHookRegistry,
} from '@/qqbot/plugins/bangDream/hook/hook-registry';
} from '@/modules/qqbot/plugins/bangDream/hook/hook-registry';
export type TsuguOperationPipelineOptions = {
executeHandler: (

View File

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

View File

Before

Width:  |  Height:  |  Size: 445 KiB

After

Width:  |  Height:  |  Size: 445 KiB

View File

Before

Width:  |  Height:  |  Size: 594 KiB

After

Width:  |  Height:  |  Size: 594 KiB

View File

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Before

Width:  |  Height:  |  Size: 524 B

After

Width:  |  Height:  |  Size: 524 B

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 223 B

After

Width:  |  Height:  |  Size: 223 B

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Some files were not shown because too many files have changed in this diff Show More