Compare commits

...

10 Commits

412 changed files with 11644 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

@ -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

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

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