Compare commits
10 Commits
c197edada4
...
fd22836c23
| Author | SHA1 | Date | |
|---|---|---|---|
| fd22836c23 | |||
| cf89157fd6 | |||
| bf8aee3a5a | |||
| 7713d6c1ab | |||
| 7c32cda506 | |||
| 679aabb40d | |||
| 51f05acf55 | |||
| c5f42251bc | |||
| 3c3dc3a69f | |||
| 9db2e4bc5f |
80
API.md
@ -52,20 +52,20 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
|
|||||||
|
|
||||||
## Runtime Health
|
## Runtime Health
|
||||||
|
|
||||||
| 方法 | 路径 | 认证 | 说明 |
|
| 方法 | 路径 | 认证 | 说明 |
|
||||||
| ----- | ----------------- | ---- | ---------------------------------- |
|
| ----- | ----------------- | ---- | ---------------------------- |
|
||||||
| `GET` | `/health/runtime` | 否 | API 运行时健康和配置检查状态 |
|
| `GET` | `/health/runtime` | 否 | API 运行时健康和配置检查状态 |
|
||||||
|
|
||||||
该接口返回 plain JSON,不使用 Vben 响应包装,供本地 smoke、Jenkins/K8s 和 ktWorkflow 观测脚本直接读取。接口位于 Swagger 基础能力分组 `/api/basic`。
|
该接口返回 plain JSON,不使用 Vben 响应包装,供本地 smoke、Jenkins/K8s 和 ktWorkflow 观测脚本直接读取。接口位于 Swagger 基础能力分组 `/api/basic`。
|
||||||
|
|
||||||
顶层字段:
|
顶层字段:
|
||||||
|
|
||||||
| 字段 | 说明 |
|
| 字段 | 说明 |
|
||||||
| ----------- | ------------------------------------------------ |
|
| ----------- | ---------------------------------------- |
|
||||||
| `service` | 固定为 `kt-template-online-api` |
|
| `service` | 固定为 `kt-template-online-api` |
|
||||||
| `checkedAt` | ISO 时间字符串 |
|
| `checkedAt` | ISO 时间字符串 |
|
||||||
| `status` | `live`、`ready`、`degraded` 或 `blocked` |
|
| `status` | `live`、`ready`、`degraded` 或 `blocked` |
|
||||||
| `checks` | 进程和配置检查列表 |
|
| `checks` | 进程和配置检查列表 |
|
||||||
|
|
||||||
公开响应不返回数据库、WordPress、Loki、NapCat SSH 等运行拓扑配置快照;配置检查只暴露 key 级别、是否存在和缺失说明。
|
公开响应不返回数据库、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` |
|
| 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` |
|
| 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` |
|
| 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 | `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` |
|
| 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_*` |
|
| 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_*` |
|
| NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` |
|
||||||
| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` |
|
| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` |
|
||||||
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
|
| 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` |
|
| 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` |
|
| FFLogs | `FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
|
||||||
|
|
||||||
真实密码、Token、OAuth secret 和生产 env 不提交到 Git。
|
真实密码、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 顺序,但不会自动进入扫码阶段。
|
扫码链路返回 `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` 默认镜像。
|
同一 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 分钟内只复读一次,并限制普通文本长度,减少自动行为被风控识别的概率。
|
外发消息不直接抢发:后端会按 `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
|
### Command / Rule / Permission
|
||||||
@ -407,6 +411,36 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
|
|||||||
| `GET` | `/qqbot/conversation/list` | 会话列表 |
|
| `GET` | `/qqbot/conversation/list` | 会话列表 |
|
||||||
| `GET` | `/qqbot/message/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
|
### OneBot Reverse WebSocket
|
||||||
|
|
||||||
`QQBOT_REVERSE_WS_PATH` 默认是 `/qqbot/onebot/reverse`。NapCat 通过反向 WS 连接 API,token 使用 `QQBOT_REVERSE_WS_TOKEN`。
|
`QQBOT_REVERSE_WS_PATH` 默认是 `/qqbot/onebot/reverse`。NapCat 通过反向 WS 连接 API,token 使用 `QQBOT_REVERSE_WS_TOKEN`。
|
||||||
@ -415,7 +449,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
|
|||||||
|
|
||||||
### BangDream
|
### BangDream
|
||||||
|
|
||||||
插件 key:`bangDream`。当前源码根目录为 `src/qqbot/plugins/bangDream`,不再使用旧 `tsugu` 子目录。
|
插件 key:`bangdream`。旧 `bangDream` 作为兼容别名仍可解析;当前源码根目录为 `src/modules/qqbot/plugins/bangDream`,不再使用旧 `tsugu` 子目录。
|
||||||
|
|
||||||
| operation key | 命令 | 说明 |
|
| operation key | 命令 | 说明 |
|
||||||
| ----------------------------- | ------------- | ------------------------ |
|
| ----------------------------- | ------------- | ------------------------ |
|
||||||
@ -439,7 +473,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
|
|||||||
|
|
||||||
### FF14 Market
|
### FF14 Market
|
||||||
|
|
||||||
插件 key:`ff14Market`。
|
插件 key:`ff14-market`。旧 `ff14Market` 作为兼容别名仍可解析。
|
||||||
|
|
||||||
| operation key | 说明 |
|
| operation key | 说明 |
|
||||||
| ------------------- | ------------------------------------------ |
|
| ------------------- | ------------------------------------------ |
|
||||||
|
|||||||
55
README.md
@ -16,17 +16,18 @@
|
|||||||
|
|
||||||
## 功能模块
|
## 功能模块
|
||||||
|
|
||||||
| 模块 | 说明 |
|
| 模块 | 说明 |
|
||||||
| -------------------------- | -------------------------------------------------------------------------------- |
|
| ------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志 |
|
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志 |
|
||||||
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
|
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
|
||||||
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
|
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
|
||||||
| `qqbot` | QQBot 账号、NapCat 扫码登录、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志 |
|
| `qqbot` | QQBot 账号、NapCat 扫码登录、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 |
|
||||||
| `qqbot/plugins/bangDream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
|
| `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、受控 SDK 和 CLI 脚手架 |
|
||||||
| `qqbot/plugins/ff14Market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
|
| `qqbot/plugins/bangDream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
|
||||||
| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 |
|
| `qqbot/plugins/ff14Market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
|
||||||
| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除 |
|
| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 |
|
||||||
| `common` | 响应封装、异常过滤、请求日志、日期格式化、字典解码、Snowflake、工具服务 |
|
| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除 |
|
||||||
|
| `common` | 响应封装、异常过滤、请求日志、日期格式化、字典解码、Snowflake、工具服务 |
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
@ -36,6 +37,7 @@ src/
|
|||||||
blog/ 本地博客内容与主题配置
|
blog/ 本地博客内容与主题配置
|
||||||
common/ 全局装饰器、过滤器、拦截器、logger、工具和类型
|
common/ 全局装饰器、过滤器、拦截器、logger、工具和类型
|
||||||
minio/ MinIO 文件服务
|
minio/ MinIO 文件服务
|
||||||
|
modules/ 第三期重构后的业务边界模块
|
||||||
qqbot/ QQBot 运行态、管理接口和插件生态
|
qqbot/ QQBot 运行态、管理接口和插件生态
|
||||||
wordpress/ WordPress REST 代理
|
wordpress/ WordPress REST 代理
|
||||||
app.module.ts
|
app.module.ts
|
||||||
@ -53,17 +55,17 @@ ci/ Jenkins Agent/Docker 辅助文件
|
|||||||
|
|
||||||
主要配置分组:
|
主要配置分组:
|
||||||
|
|
||||||
| 分组 | 变量 |
|
| 分组 | 变量 |
|
||||||
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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_*` |
|
| 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_*` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
|
||||||
|
|
||||||
`DB_SYNC=true` 只适合本地开发或明确允许自动同步表结构的环境;生产应关闭并使用 SQL/迁移脚本。
|
`DB_SYNC=true` 只适合本地开发或明确允许自动同步表结构的环境;生产应关闭并使用 SQL/迁移脚本。
|
||||||
|
|
||||||
@ -85,6 +87,10 @@ pnpm run typecheck
|
|||||||
pnpm run lint
|
pnpm run lint
|
||||||
pnpm test
|
pnpm test
|
||||||
pnpm run build
|
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 下指定测试文件,使用:
|
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 外发统一走发送排队:默认全局间隔 `2500ms`、同会话间隔 `8000ms`、排队抖动 `0-800ms`,超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS` 时拒绝本次发送,避免高频自动回复形成突发流量。
|
||||||
- QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。
|
- QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。
|
||||||
- QQBot 复读机默认阈值为 4,同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。
|
- 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`。
|
- 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 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。
|
||||||
- NapCat 账号新增/编辑支持可选 QQ 登录密码:Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。
|
- NapCat 账号新增/编辑支持可选 QQ 登录密码:Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。
|
||||||
- NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。`ACCOUNT` 只负责指定快速登录账号;如果数据卷内没有该 QQ 的历史登录记录,后端会跳过快速登录并优先尝试账号保存的登录密码,密码登录会按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果,准备中的扫码会话会续期,避免后台密码登录未结束时前端先过期;如果 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 容器为已知 `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` 站内信告警;看门狗不自动进入扫码阶段。
|
- 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 在线命令以 `registry/operation-registry.ts` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 registry/command-SQL 测试。
|
||||||
- BangDream event stage 大图必须保持分页拆图行为,线上 smoke 关注 `imageCount=5`,避免大 canvas OOM 回归。
|
- BangDream event stage 大图必须保持分页拆图行为,线上 smoke 关注 `imageCount=5`,避免大 canvas OOM 回归。
|
||||||
|
|
||||||
@ -187,4 +196,4 @@ BangDream 图片能力改动:
|
|||||||
|
|
||||||
| 一级来源 | 使用方式 | License |
|
| 一级来源 | 使用方式 | 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 |
|
||||||
|
|||||||
12
docs/refactor-v3/api-admin-contract-matrix.md
Normal 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 |
|
||||||
20
docs/refactor-v3/breaking-changes.md
Normal 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.
|
||||||
31
docs/refactor-v3/rebuild-runbook.md
Normal 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.
|
||||||
25
docs/refactor-v3/schema-map.md
Normal 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. |
|
||||||
1588
docs/superpowers/plans/2026-06-15-api-admin-full-refactor-v3.md
Normal file
@ -22,6 +22,7 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
||||||
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"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:staged": "node scripts/husky-fast-check.mjs",
|
||||||
"verify:commit": "pnpm run lint && pnpm run typecheck",
|
"verify:commit": "pnpm run lint && pnpm run typecheck",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
|
|||||||
@ -48,8 +48,8 @@ const path = require("path");
|
|||||||
const { ConfigService } = require("@nestjs/config");
|
const { ConfigService } = require("@nestjs/config");
|
||||||
const payload = JSON.parse(Buffer.from("$PayloadBase64", "base64").toString("utf8"));
|
const payload = JSON.parse(Buffer.from("$PayloadBase64", "base64").toString("utf8"));
|
||||||
const { ToolsService } = require("./src/common/services/tool.service");
|
const { ToolsService } = require("./src/common/services/tool.service");
|
||||||
const { QqbotBangDreamRendererService } = require("./src/qqbot/plugins/bangDream/application/bangdream-renderer.facade");
|
const { QqbotBangDreamRendererService } = require("./src/modules/qqbot/plugins/bangDream/application/bangdream-renderer.facade");
|
||||||
const { TsuguApplicationService } = require("./src/qqbot/plugins/bangDream/application/bangdream-application.service");
|
const { TsuguApplicationService } = require("./src/modules/qqbot/plugins/bangDream/application/bangdream-application.service");
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const renderer = new QqbotBangDreamRendererService(new ConfigService({}), undefined);
|
const renderer = new QqbotBangDreamRendererService(new ConfigService({}), undefined);
|
||||||
|
|||||||
312
scripts/qqbot-plugin/cli.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
54
scripts/qqbot-plugin/templates/basic/plugin.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
11
scripts/qqbot-plugin/templates/basic/src/index.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export async function echo(input: { text?: string }) {
|
||||||
|
return {
|
||||||
|
replyText: input.text || 'pong',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function onMessage() {
|
||||||
|
return {
|
||||||
|
handled: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
14
scripts/refactor-v3/db-backup-online.ps1
Normal 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
|
||||||
41
scripts/refactor-v3/db-dry-run.ps1
Normal 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
|
||||||
16
scripts/refactor-v3/db-restore-online.ps1
Normal 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"
|
||||||
10
scripts/refactor-v3/local-smoke.ps1
Normal 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)"
|
||||||
987
sql/refactor-v3/00-full-schema.sql
Normal 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;
|
||||||
294
sql/refactor-v3/01-seed-core.sql
Normal 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);
|
||||||
62
sql/refactor-v3/99-verify.sql
Normal 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';
|
||||||
@ -6,7 +6,6 @@ import { LoggerModule } from 'nestjs-pino';
|
|||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { MinioModule } from 'nestjs-minio-client';
|
import { MinioModule } from 'nestjs-minio-client';
|
||||||
import { MinioClientModule } from './minio/minio.module';
|
|
||||||
import {
|
import {
|
||||||
ApiRequestLogInterceptor,
|
ApiRequestLogInterceptor,
|
||||||
ApiExceptionFilter,
|
ApiExceptionFilter,
|
||||||
@ -14,10 +13,12 @@ import {
|
|||||||
createPinoLoggerParams,
|
createPinoLoggerParams,
|
||||||
SaveBodyInterceptor,
|
SaveBodyInterceptor,
|
||||||
} from './common';
|
} from './common';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './modules/admin/admin.module';
|
||||||
import { BlogModule } from './blog/blog.module';
|
import { AssetModule } from './modules/asset/asset.module';
|
||||||
import { WordpressModule } from './wordpress/wordpress.module';
|
import { BlogContentModule } from './modules/blog/blog-content.module';
|
||||||
import { QqbotModule } from './qqbot/qqbot.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';
|
import { RuntimeModule } from './runtime';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -43,7 +44,10 @@ import { RuntimeModule } from './runtime';
|
|||||||
password: configService.get('DB_PASSWORD'),
|
password: configService.get('DB_PASSWORD'),
|
||||||
database: configService.get('DB_DATABASE'),
|
database: configService.get('DB_DATABASE'),
|
||||||
synchronize: configService.get<string>('DB_SYNC') === 'true',
|
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}'],
|
subscribers: [__dirname + '/**/*.subscriber{.ts,.js}'],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -63,13 +67,14 @@ import { RuntimeModule } from './runtime';
|
|||||||
},
|
},
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
}),
|
}),
|
||||||
MinioClientModule,
|
|
||||||
CommonModule,
|
CommonModule,
|
||||||
RuntimeModule,
|
RuntimeModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
BlogModule,
|
BlogContentModule,
|
||||||
WordpressModule,
|
WordpressMirrorModule,
|
||||||
QqbotModule,
|
AssetModule,
|
||||||
|
QqbotCoreModule,
|
||||||
|
QqbotPluginPlatformModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AppService,
|
AppService,
|
||||||
|
|||||||
8
src/modules/admin/admin.module.ts
Normal 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 {}
|
||||||
45
src/modules/admin/identity/admin-identity.module.ts
Normal 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 {}
|
||||||
@ -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 {}
|
||||||
47
src/modules/asset/asset.module.ts
Normal 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 {}
|
||||||
66
src/modules/blog/blog-content.module.ts
Normal 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 {}
|
||||||
74
src/modules/qqbot/core/qqbot-core.contract.ts
Normal 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;
|
||||||
151
src/modules/qqbot/core/qqbot-core.module.ts
Normal 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 {}
|
||||||
@ -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"',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/modules/qqbot/napcat/index.ts
Normal 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';
|
||||||
109
src/modules/qqbot/napcat/integration/napcat-login-api.client.ts
Normal 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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
277
src/modules/qqbot/napcat/login/napcat-login-state-machine.ts
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/modules/qqbot/napcat/persistence.ts
Normal 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];
|
||||||
2
src/modules/qqbot/plugin-platform/manifest/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './manifest.parser';
|
||||||
|
export * from './manifest.types';
|
||||||
395
src/modules/qqbot/plugin-platform/manifest/manifest.parser.ts
Normal 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;
|
||||||
|
};
|
||||||
88
src/modules/qqbot/plugin-platform/manifest/manifest.types.ts
Normal 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;
|
||||||
|
};
|
||||||
2
src/modules/qqbot/plugin-platform/persistence/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './plugin-platform.contract';
|
||||||
|
export * from './plugin-platform.entities';
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
135
src/modules/qqbot/plugin-platform/plugin-platform.controller.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/modules/qqbot/plugin-platform/plugin-platform.module.ts
Normal 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 {}
|
||||||
168
src/modules/qqbot/plugin-platform/plugin-platform.service.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/modules/qqbot/plugin-platform/runtime/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './worker-runtime';
|
||||||
|
export * from './worker-runtime.types';
|
||||||
200
src/modules/qqbot/plugin-platform/runtime/worker-runtime.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
};
|
||||||
2
src/modules/qqbot/plugin-platform/sdk/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './plugin-sdk';
|
||||||
|
export * from './plugin-http-client.service';
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/modules/qqbot/plugin-platform/sdk/plugin-sdk.ts
Normal 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -4,15 +4,15 @@ import type {
|
|||||||
QqbotBangDreamCommandInput,
|
QqbotBangDreamCommandInput,
|
||||||
QqbotBangDreamCommandOutput,
|
QqbotBangDreamCommandOutput,
|
||||||
QqbotBangDreamOperationKey,
|
QqbotBangDreamOperationKey,
|
||||||
} from '@/qqbot/plugins/bangDream/qqbot-bangdream.types';
|
} from '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.types';
|
||||||
import { waitForMainDataReady } from '@/qqbot/plugins/bangDream/shared/main-data-store';
|
import { waitForMainDataReady } from '@/modules/qqbot/plugins/bangDream/shared/main-data-store';
|
||||||
import {
|
import {
|
||||||
createTsuguLogHook,
|
createTsuguLogHook,
|
||||||
TsuguHookRegistry,
|
TsuguHookRegistry,
|
||||||
} from '@/qqbot/plugins/bangDream/hook/hook-registry';
|
} from '@/modules/qqbot/plugins/bangDream/hook/hook-registry';
|
||||||
import { getBangDreamOperationDefinition } from '@/qqbot/plugins/bangDream/registry/operation-registry';
|
import { getBangDreamOperationDefinition } from '@/modules/qqbot/plugins/bangDream/registry/operation-registry';
|
||||||
import { TsuguOperationPipeline } from '@/qqbot/plugins/bangDream/application/operation-pipeline';
|
import { TsuguOperationPipeline } from '@/modules/qqbot/plugins/bangDream/application/operation-pipeline';
|
||||||
import { QqbotBangDreamRendererService } from '@/qqbot/plugins/bangDream/application/bangdream-renderer.facade';
|
import { QqbotBangDreamRendererService } from '@/modules/qqbot/plugins/bangDream/application/bangdream-renderer.facade';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TsuguApplicationService implements OnApplicationBootstrap {
|
export class TsuguApplicationService implements OnApplicationBootstrap {
|
||||||
@ -4,8 +4,8 @@ import type {
|
|||||||
QqbotBangDreamOperationKey,
|
QqbotBangDreamOperationKey,
|
||||||
QqbotBangDreamSongSearchInput,
|
QqbotBangDreamSongSearchInput,
|
||||||
QqbotBangDreamSongSummary,
|
QqbotBangDreamSongSummary,
|
||||||
} from '@/qqbot/plugins/bangDream/qqbot-bangdream.types';
|
} from '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.types';
|
||||||
import { TsuguApplicationService } from '@/qqbot/plugins/bangDream/application/bangdream-application.service';
|
import { TsuguApplicationService } from '@/modules/qqbot/plugins/bangDream/application/bangdream-application.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QqbotBangDreamClientService {
|
export class QqbotBangDreamClientService {
|
||||||
@ -1,54 +1,59 @@
|
|||||||
import { Injectable, Optional } from '@nestjs/common';
|
import { Injectable, Optional } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { DictService } from '@/admin/dict/dict.service';
|
import { DictService } from '@/admin/dict/dict.service';
|
||||||
import { Card } from '@/qqbot/plugins/bangDream/card/card.model';
|
import { Card } from '@/modules/qqbot/plugins/bangDream/card/card.model';
|
||||||
import { getPresentEvent } from '@/qqbot/plugins/bangDream/event/event.model';
|
import { getPresentEvent } from '@/modules/qqbot/plugins/bangDream/event/event.model';
|
||||||
import { Gacha, getPresentGachaList } from '@/qqbot/plugins/bangDream/gacha/gacha.model';
|
import {
|
||||||
|
Gacha,
|
||||||
|
getPresentGachaList,
|
||||||
|
} from '@/modules/qqbot/plugins/bangDream/gacha/gacha.model';
|
||||||
import {
|
import {
|
||||||
BANGDREAM_GACHA_DEFAULT_SPIN_COUNT,
|
BANGDREAM_GACHA_DEFAULT_SPIN_COUNT,
|
||||||
isBirthdayGachaType,
|
isBirthdayGachaType,
|
||||||
} from '@/qqbot/plugins/bangDream/policy/gacha.policy';
|
} from '@/modules/qqbot/plugins/bangDream/policy/gacha.policy';
|
||||||
import { Server } from '@/qqbot/plugins/bangDream/catalog/server.model';
|
import { Server } from '@/modules/qqbot/plugins/bangDream/catalog/server.model';
|
||||||
import { Song } from '@/qqbot/plugins/bangDream/song/song.model';
|
import { Song } from '@/modules/qqbot/plugins/bangDream/song/song.model';
|
||||||
import mainAPI, { waitForMainDataReady } from '@/qqbot/plugins/bangDream/shared/main-data-store';
|
import mainAPI, {
|
||||||
|
waitForMainDataReady,
|
||||||
|
} from '@/modules/qqbot/plugins/bangDream/shared/main-data-store';
|
||||||
import {
|
import {
|
||||||
fuzzySearch,
|
fuzzySearch,
|
||||||
type FuzzySearchResult,
|
type FuzzySearchResult,
|
||||||
} from '@/qqbot/plugins/bangDream/search/fuzzy-search';
|
} from '@/modules/qqbot/plugins/bangDream/search/fuzzy-search';
|
||||||
import { drawCardDetail } from '@/qqbot/plugins/bangDream/card/card-detail.renderer';
|
import { drawCardDetail } from '@/modules/qqbot/plugins/bangDream/card/card-detail.renderer';
|
||||||
import { drawCardList } from '@/qqbot/plugins/bangDream/card/card-search.renderer';
|
import { drawCardList } from '@/modules/qqbot/plugins/bangDream/card/card-search.renderer';
|
||||||
import { drawCharacterDetail } from '@/qqbot/plugins/bangDream/character/character-detail.renderer';
|
import { drawCharacterDetail } from '@/modules/qqbot/plugins/bangDream/character/character-detail.renderer';
|
||||||
import { drawCharacterList } from '@/qqbot/plugins/bangDream/character/character-search.renderer';
|
import { drawCharacterList } from '@/modules/qqbot/plugins/bangDream/character/character-search.renderer';
|
||||||
import { drawCutoffAll } from '@/qqbot/plugins/bangDream/cutoff/cutoff-all.renderer';
|
import { drawCutoffAll } from '@/modules/qqbot/plugins/bangDream/cutoff/cutoff-all.renderer';
|
||||||
import { drawCutoffDetail } from '@/qqbot/plugins/bangDream/cutoff/cutoff-detail.renderer';
|
import { drawCutoffDetail } from '@/modules/qqbot/plugins/bangDream/cutoff/cutoff-detail.renderer';
|
||||||
import { drawCutoffEventTop } from '@/qqbot/plugins/bangDream/cutoff/cutoff-event-top.renderer';
|
import { drawCutoffEventTop } from '@/modules/qqbot/plugins/bangDream/cutoff/cutoff-event-top.renderer';
|
||||||
import { drawCutoffListOfRecentEvent } from '@/qqbot/plugins/bangDream/cutoff/cutoff-recent.renderer';
|
import { drawCutoffListOfRecentEvent } from '@/modules/qqbot/plugins/bangDream/cutoff/cutoff-recent.renderer';
|
||||||
import { drawEventDetail } from '@/qqbot/plugins/bangDream/event/event-detail.renderer';
|
import { drawEventDetail } from '@/modules/qqbot/plugins/bangDream/event/event-detail.renderer';
|
||||||
import { drawEventList } from '@/qqbot/plugins/bangDream/event/event-search.renderer';
|
import { drawEventList } from '@/modules/qqbot/plugins/bangDream/event/event-search.renderer';
|
||||||
import { drawEventStage } from '@/qqbot/plugins/bangDream/event/event-stage.renderer';
|
import { drawEventStage } from '@/modules/qqbot/plugins/bangDream/event/event-stage.renderer';
|
||||||
import { drawGachaDetail } from '@/qqbot/plugins/bangDream/gacha/gacha-detail.renderer';
|
import { drawGachaDetail } from '@/modules/qqbot/plugins/bangDream/gacha/gacha-detail.renderer';
|
||||||
import { drawRandomGacha } from '@/qqbot/plugins/bangDream/gacha/gacha-simulate.renderer';
|
import { drawRandomGacha } from '@/modules/qqbot/plugins/bangDream/gacha/gacha-simulate.renderer';
|
||||||
import { drawPlayerDetail } from '@/qqbot/plugins/bangDream/player/player-detail.renderer';
|
import { drawPlayerDetail } from '@/modules/qqbot/plugins/bangDream/player/player-detail.renderer';
|
||||||
import { drawSongChart } from '@/qqbot/plugins/bangDream/song/song-chart.renderer';
|
import { drawSongChart } from '@/modules/qqbot/plugins/bangDream/song/song-chart.renderer';
|
||||||
import { drawSongDetail } from '@/qqbot/plugins/bangDream/song/song-detail.renderer';
|
import { drawSongDetail } from '@/modules/qqbot/plugins/bangDream/song/song-detail.renderer';
|
||||||
import { drawSongList } from '@/qqbot/plugins/bangDream/song/song-search.renderer';
|
import { drawSongList } from '@/modules/qqbot/plugins/bangDream/song/song-search.renderer';
|
||||||
import { drawSongMetaList } from '@/qqbot/plugins/bangDream/song/song-meta.renderer';
|
import { drawSongMetaList } from '@/modules/qqbot/plugins/bangDream/song/song-meta.renderer';
|
||||||
import { drawSongRandom } from '@/qqbot/plugins/bangDream/song/song-random.renderer';
|
import { drawSongRandom } from '@/modules/qqbot/plugins/bangDream/song/song-random.renderer';
|
||||||
import {
|
import {
|
||||||
BangDreamDictionaryLoader,
|
BangDreamDictionaryLoader,
|
||||||
type BangDreamDictionaryItem,
|
type BangDreamDictionaryItem,
|
||||||
} from '@/qqbot/plugins/bangDream/dictionary/dictionary-loader';
|
} from '@/modules/qqbot/plugins/bangDream/dictionary/dictionary-loader';
|
||||||
import type { QqbotBangDreamOperationHandlerName } from '@/qqbot/plugins/bangDream/registry/operation-registry';
|
import type { QqbotBangDreamOperationHandlerName } from '@/modules/qqbot/plugins/bangDream/registry/operation-registry';
|
||||||
import {
|
import {
|
||||||
BANGDREAM_TSUGU_ENV_KEYS,
|
BANGDREAM_TSUGU_ENV_KEYS,
|
||||||
normalizeBangDreamBoolean,
|
normalizeBangDreamBoolean,
|
||||||
splitBangDreamOptionList,
|
splitBangDreamOptionList,
|
||||||
} from '@/qqbot/plugins/bangDream/config/runtime-options';
|
} from '@/modules/qqbot/plugins/bangDream/config/runtime-options';
|
||||||
import type {
|
import type {
|
||||||
QqbotBangDreamCommandInput,
|
QqbotBangDreamCommandInput,
|
||||||
QqbotBangDreamCommandOutput,
|
QqbotBangDreamCommandOutput,
|
||||||
QqbotBangDreamOperationKey,
|
QqbotBangDreamOperationKey,
|
||||||
} from '@/qqbot/plugins/bangDream/qqbot-bangdream.types';
|
} from '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.types';
|
||||||
|
|
||||||
const SOURCE_NAME = 'Tsugu BangDream Bot 内置源码';
|
const SOURCE_NAME = 'Tsugu BangDream Bot 内置源码';
|
||||||
|
|
||||||
@ -2,15 +2,15 @@ import type {
|
|||||||
QqbotBangDreamCommandInput,
|
QqbotBangDreamCommandInput,
|
||||||
QqbotBangDreamCommandOutput,
|
QqbotBangDreamCommandOutput,
|
||||||
QqbotBangDreamOperationKey,
|
QqbotBangDreamOperationKey,
|
||||||
} from '@/qqbot/plugins/bangDream/qqbot-bangdream.types';
|
} from '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.types';
|
||||||
import type {
|
import type {
|
||||||
QqbotBangDreamOperationHandlerName,
|
QqbotBangDreamOperationHandlerName,
|
||||||
TsuguOperationDefinition,
|
TsuguOperationDefinition,
|
||||||
} from '@/qqbot/plugins/bangDream/registry/operation-registry';
|
} from '@/modules/qqbot/plugins/bangDream/registry/operation-registry';
|
||||||
import {
|
import {
|
||||||
createTsuguHookContext,
|
createTsuguHookContext,
|
||||||
type TsuguHookRegistry,
|
type TsuguHookRegistry,
|
||||||
} from '@/qqbot/plugins/bangDream/hook/hook-registry';
|
} from '@/modules/qqbot/plugins/bangDream/hook/hook-registry';
|
||||||
|
|
||||||
export type TsuguOperationPipelineOptions = {
|
export type TsuguOperationPipelineOptions = {
|
||||||
executeHandler: (
|
executeHandler: (
|
||||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 445 KiB After Width: | Height: | Size: 445 KiB |
|
Before Width: | Height: | Size: 594 KiB After Width: | Height: | Size: 594 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 524 B After Width: | Height: | Size: 524 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 223 B After Width: | Height: | Size: 223 B |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |