Compare commits
10 Commits
c197edada4
...
fd22836c23
| Author | SHA1 | Date | |
|---|---|---|---|
| fd22836c23 | |||
| cf89157fd6 | |||
| bf8aee3a5a | |||
| 7713d6c1ab | |||
| 7c32cda506 | |||
| 679aabb40d | |||
| 51f05acf55 | |||
| c5f42251bc | |||
| 3c3dc3a69f | |||
| 9db2e4bc5f |
44
API.md
@ -53,7 +53,7 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
|
||||
## Runtime Health
|
||||
|
||||
| 方法 | 路径 | 认证 | 说明 |
|
||||
| ----- | ----------------- | ---- | ---------------------------------- |
|
||||
| ----- | ----------------- | ---- | ---------------------------- |
|
||||
| `GET` | `/health/runtime` | 否 | API 运行时健康和配置检查状态 |
|
||||
|
||||
该接口返回 plain JSON,不使用 Vben 响应包装,供本地 smoke、Jenkins/K8s 和 ktWorkflow 观测脚本直接读取。接口位于 Swagger 基础能力分组 `/api/basic`。
|
||||
@ -61,7 +61,7 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
|
||||
顶层字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| ----------- | ------------------------------------------------ |
|
||||
| ----------- | ---------------------------------------- |
|
||||
| `service` | 固定为 `kt-template-online-api` |
|
||||
| `checkedAt` | ISO 时间字符串 |
|
||||
| `status` | `live`、`ready`、`degraded` 或 `blocked` |
|
||||
@ -79,7 +79,7 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
|
||||
## 环境变量分组
|
||||
|
||||
| 分组 | 关键变量 |
|
||||
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
|
||||
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET` |
|
||||
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
|
||||
@ -345,8 +345,12 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
|
||||
|
||||
扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。已有账号的更新登录会先重启目标 NapCat 容器尝试 `ACCOUNT`/`-q` 快速登录;目标账号在线则直接完成会话。没有历史登录态的新容器会跳过快速登录,优先尝试保存的登录密码;快速登录失败后,如果账号保存了登录密码,会临时注入 `NAPCAT_QUICK_PASSWORD` 并按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询密码登录结果,准备阶段的扫码会话会持续续期,避免后台密码登录未完成时前端先判过期。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。会话已有 `captchaUrl` 后,`/qqbot/account/scan/status` 遇到 NapCat 继续返回“需要验证码/继续完成验证/安全验证”但不带 URL 时仍保持 `pending` 和原 `captchaUrl`。密码登录成功后会重建容器移除该运行态密码,清理失败则本次登录失败;密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,再进入重置登录态和二维码兜底流程。看门狗自动登录使用同样的 quick -> password 顺序,但不会自动进入扫码阶段。
|
||||
|
||||
密码验证码通过后如果 NapCat 返回 `needNewDevice`,后端不会只把 `jumpUrl` 透给 Admin,而是在同一会话中继续调用 `/api/QQLogin/GetNewDeviceQRCode` 生成新设备验证二维码;`/qqbot/account/scan/status` 后续轮询会代理 `/api/QQLogin/PollNewDeviceQR`,状态映射为 `newDeviceStatus=qr-pending|scanned|confirming|verified|expired|failed`,进入确认态后再调用 `/api/QQLogin/NewDeviceLogin` 并回到密码登录完成检查。扫码会话结果新增 `newDeviceQrcode`、`newDeviceStatus`、`deviceVerifyUrl` 字段;`captchaUrl` 和 `newDeviceQrcode` 分别表示腾讯安全验证码和 QQ 新设备验证二维码,前端必须分开展示。SSE 进度文案包含快速登录、密码登录、验证码、新设备二维码、已扫码、确认中、登录成功/失败和运行态清理失败。
|
||||
|
||||
同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器,后端会释放旧绑定和未共享的旧容器,避免同账号多实例互相挤下线。OneBot notice 只有机器人下线、登录失效、`KickedOffLine` 等账号级信号才会记录 QQ 登录态异常并生成 `qqbot.account.offline` 站内信,普通群成员 kick 不属于账号离线信号。下线原因写入 `lastError` 前按 `last_error` 500 字符列宽截断;后续无错误的普通断连只更新 OneBot 连接状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于 QQ 登录态离线信号;心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态。托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像。
|
||||
|
||||
托管 NapCat 容器按账号持久化设备身份,`napcat_device_identity` 保存账号对应的数据目录、hostname、machine-id 路径、MAC 地址、验证状态和最近登录证据。重建同一账号容器时会复用这些设备字段,并在 Docker run 中注入 `--hostname`、`--mac-address` 和只读 `/etc/machine-id` 挂载,降低每次重建都被 QQ 判定为新设备的概率。
|
||||
|
||||
外发消息不直接抢发:后端会按 `QQBOT_SEND_GLOBAL_INTERVAL_MS`、`QQBOT_SEND_TARGET_INTERVAL_MS` 和 `QQBOT_SEND_JITTER_MS` 预约发送窗口,默认全局 2500ms、同会话 8000ms、抖动 0-800ms;如果等待超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS`,本次发送会在下发前被拒绝。在线命令和自动回复规则会叠加运行时保底冷却,默认命令 5000ms、规则 30000ms;复读机默认连续 4 次相同普通文本才触发,同一会话默认 10 分钟内只复读一次,并限制普通文本长度,减少自动行为被风控识别的概率。
|
||||
|
||||
### Command / Rule / Permission
|
||||
@ -407,6 +411,36 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
|
||||
| `GET` | `/qqbot/conversation/list` | 会话列表 |
|
||||
| `GET` | `/qqbot/message/list` | 消息列表 |
|
||||
|
||||
### Plugin Platform
|
||||
|
||||
插件平台使用统一 `plugin.json` manifest 描述插件 key、版本、入口、操作、事件、权限和运行预算;后端会校验路径必须留在插件包内,权限必须命中白名单,安装包 content hash 必须与 manifest 匹配。CLI 入口:
|
||||
|
||||
```bash
|
||||
pnpm qqbot-plugin create <pluginKey>
|
||||
pnpm qqbot-plugin validate <pluginDir>
|
||||
pnpm qqbot-plugin pack <pluginDir>
|
||||
pnpm qqbot-plugin install-local <packageFile>
|
||||
```
|
||||
|
||||
平台管理接口:
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ------ | ----------------------------------------- | ---------------------------------- |
|
||||
| `GET` | `/qqbot/plugin-platform/installations` | 插件安装记录,支持 key/status 过滤 |
|
||||
| `POST` | `/qqbot/plugin-platform/upload` | 上传插件包并返回校验摘要 |
|
||||
| `POST` | `/qqbot/plugin-platform/validate` | 校验 manifest JSON |
|
||||
| `POST` | `/qqbot/plugin-platform/install` | 按上传包安装插件版本 |
|
||||
| `POST` | `/qqbot/plugin-platform/install-local` | 按本地包路径安装插件版本 |
|
||||
| `POST` | `/qqbot/plugin-platform/enable` | 启用插件安装 |
|
||||
| `POST` | `/qqbot/plugin-platform/disable` | 禁用插件安装 |
|
||||
| `POST` | `/qqbot/plugin-platform/upgrade` | 升级插件安装版本 |
|
||||
| `POST` | `/qqbot/plugin-platform/uninstall` | 卸载插件安装 |
|
||||
| `POST` | `/qqbot/plugin-platform/config` | 保存插件配置 |
|
||||
| `GET` | `/qqbot/plugin-platform/runtime-events` | 查询插件运行事件 |
|
||||
| `GET` | `/qqbot/plugin-platform/account-bindings` | 查询插件账号绑定 |
|
||||
|
||||
`src/modules/qqbot/plugin-platform/runtime` 当前提供 host-side driver 边界和超时/崩溃事件归档;实际 worker/child-process driver 可以在后续批次接入,但插件侧只能通过受控 SDK 访问发送队列、配置、存储、HTTP、资产和事件上下文。
|
||||
|
||||
### OneBot Reverse WebSocket
|
||||
|
||||
`QQBOT_REVERSE_WS_PATH` 默认是 `/qqbot/onebot/reverse`。NapCat 通过反向 WS 连接 API,token 使用 `QQBOT_REVERSE_WS_TOKEN`。
|
||||
@ -415,7 +449,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
|
||||
|
||||
### BangDream
|
||||
|
||||
插件 key:`bangDream`。当前源码根目录为 `src/qqbot/plugins/bangDream`,不再使用旧 `tsugu` 子目录。
|
||||
插件 key:`bangdream`。旧 `bangDream` 作为兼容别名仍可解析;当前源码根目录为 `src/modules/qqbot/plugins/bangDream`,不再使用旧 `tsugu` 子目录。
|
||||
|
||||
| operation key | 命令 | 说明 |
|
||||
| ----------------------------- | ------------- | ------------------------ |
|
||||
@ -439,7 +473,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
|
||||
|
||||
### FF14 Market
|
||||
|
||||
插件 key:`ff14Market`。
|
||||
插件 key:`ff14-market`。旧 `ff14Market` 作为兼容别名仍可解析。
|
||||
|
||||
| operation key | 说明 |
|
||||
| ------------------- | ------------------------------------------ |
|
||||
|
||||
19
README.md
@ -17,11 +17,12 @@
|
||||
## 功能模块
|
||||
|
||||
| 模块 | 说明 |
|
||||
| -------------------------- | -------------------------------------------------------------------------------- |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志 |
|
||||
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
|
||||
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
|
||||
| `qqbot` | QQBot 账号、NapCat 扫码登录、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志 |
|
||||
| `qqbot` | QQBot 账号、NapCat 扫码登录、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 |
|
||||
| `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、受控 SDK 和 CLI 脚手架 |
|
||||
| `qqbot/plugins/bangDream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
|
||||
| `qqbot/plugins/ff14Market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
|
||||
| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 |
|
||||
@ -36,6 +37,7 @@ src/
|
||||
blog/ 本地博客内容与主题配置
|
||||
common/ 全局装饰器、过滤器、拦截器、logger、工具和类型
|
||||
minio/ MinIO 文件服务
|
||||
modules/ 第三期重构后的业务边界模块
|
||||
qqbot/ QQBot 运行态、管理接口和插件生态
|
||||
wordpress/ WordPress REST 代理
|
||||
app.module.ts
|
||||
@ -54,7 +56,7 @@ ci/ Jenkins Agent/Docker 辅助文件
|
||||
主要配置分组:
|
||||
|
||||
| 分组 | 变量 |
|
||||
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
|
||||
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET` |
|
||||
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
|
||||
@ -85,6 +87,10 @@ pnpm run typecheck
|
||||
pnpm run lint
|
||||
pnpm test
|
||||
pnpm run build
|
||||
pnpm qqbot-plugin create <pluginKey>
|
||||
pnpm qqbot-plugin validate <pluginDir>
|
||||
pnpm qqbot-plugin pack <pluginDir>
|
||||
pnpm qqbot-plugin install-local <packageFile>
|
||||
```
|
||||
|
||||
Jest 只扫描 `test/**/*.spec.ts`。如果在 Windows 下指定测试文件,使用:
|
||||
@ -146,12 +152,15 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow
|
||||
- QQBot 外发统一走发送排队:默认全局间隔 `2500ms`、同会话间隔 `8000ms`、排队抖动 `0-800ms`,超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS` 时拒绝本次发送,避免高频自动回复形成突发流量。
|
||||
- QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。
|
||||
- QQBot 复读机默认阈值为 4,同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。
|
||||
- QQBot 插件平台统一使用 `plugin.json` manifest 描述插件 key、版本、操作、事件、权限、运行预算和包入口;CLI 负责 create/validate/pack/install-local,后端只暴露受控 SDK 能力并通过插件维度记录安装、配置、账号绑定和运行事件。
|
||||
- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表拆开展示 OneBot、容器、WebUI 和 QQ 登录态,心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态;`qqLoginMessage` 只展示 QQ 登录态消息,WebUI 配置或请求错误留在 `lastError`。
|
||||
- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。
|
||||
- NapCat 账号新增/编辑支持可选 QQ 登录密码:Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。
|
||||
- NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。`ACCOUNT` 只负责指定快速登录账号;如果数据卷内没有该 QQ 的历史登录记录,后端会跳过快速登录并优先尝试账号保存的登录密码,密码登录会按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果,准备中的扫码会话会续期,避免后台密码登录未结束时前端先过期;如果 NapCat 返回 `proofWaterUrl` 或日志出现“需要验证码”,会保持会话 pending 并把腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`,不是让用户只打开外链;密码登录成功后会移除运行态 `NAPCAT_QUICK_PASSWORD`,清理失败则本次登录失败,避免成功态残留明文 env;密码登录也失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时才重置登录态并生成二维码。Admin「更新登录」的 SSE 步骤顺序按实际路径为 `quick-login-*`(已有历史会话)-> `password-login-*` / `password-login-captcha` -> `password-env-cleanup` -> `relogin-reset/qrcode/waiting-scan`。
|
||||
- NapCat 设备身份按账号持久化到 `napcat_device_identity`:同一账号重建容器会复用数据目录、hostname、machine-id 和 MAC,并在 Docker run 中注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id`,避免频繁重建被 QQ 判定为全新设备。
|
||||
- NapCat 新设备验证走同一 scan session:`CaptchaLogin` 返回 `needNewDevice` 后,后端继续调用 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`,Admin/SSE 分开展示 `captchaUrl`、`newDeviceQrcode`、已扫码、确认中、验证成功、登录成功/失败等中文进度,不把 `jumpUrl` 当作唯一完成入口。
|
||||
- NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000`,`QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后先尝试 `ACCOUNT` 历史会话快速登录,再尝试账号保存的登录密码,仍失败时写入离线原因并复用 `super` 站内信告警;看门狗不自动进入扫码阶段。
|
||||
- BangDream 当前源码根目录是 `src/qqbot/plugins/bangDream`;不要恢复旧 `tsugu` 层级或旧大桶目录。
|
||||
- BangDream 当前源码根目录是 `src/modules/qqbot/plugins/bangDream`;不要恢复旧 `tsugu` 层级或旧大桶目录。
|
||||
- BangDream 在线命令以 `registry/operation-registry.ts` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 registry/command-SQL 测试。
|
||||
- BangDream event stage 大图必须保持分页拆图行为,线上 smoke 关注 `imageCount=5`,避免大 canvas OOM 回归。
|
||||
|
||||
@ -187,4 +196,4 @@ BangDream 图片能力改动:
|
||||
|
||||
| 一级来源 | 使用方式 | License |
|
||||
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------- |
|
||||
| [Tsugu BangDream Bot](https://github.com/Yamamoto-2/tsugu-bangdream-bot) | BangDream QQBot 后端能力已重构合入 `src/qqbot/plugins/bangDream`,保留本地 `TSUGU-LICENSE` | MIT |
|
||||
| [Tsugu BangDream Bot](https://github.com/Yamamoto-2/tsugu-bangdream-bot) | BangDream QQBot 后端能力已重构合入 `src/modules/qqbot/plugins/bangDream`,保留本地 `TSUGU-LICENSE` | MIT |
|
||||
|
||||
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",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
||||
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"qqbot-plugin": "ts-node -r tsconfig-paths/register scripts/qqbot-plugin/cli.ts",
|
||||
"verify:staged": "node scripts/husky-fast-check.mjs",
|
||||
"verify:commit": "pnpm run lint && pnpm run typecheck",
|
||||
"test": "jest",
|
||||
|
||||
@ -48,8 +48,8 @@ const path = require("path");
|
||||
const { ConfigService } = require("@nestjs/config");
|
||||
const payload = JSON.parse(Buffer.from("$PayloadBase64", "base64").toString("utf8"));
|
||||
const { ToolsService } = require("./src/common/services/tool.service");
|
||||
const { QqbotBangDreamRendererService } = require("./src/qqbot/plugins/bangDream/application/bangdream-renderer.facade");
|
||||
const { TsuguApplicationService } = require("./src/qqbot/plugins/bangDream/application/bangdream-application.service");
|
||||
const { QqbotBangDreamRendererService } = require("./src/modules/qqbot/plugins/bangDream/application/bangdream-renderer.facade");
|
||||
const { TsuguApplicationService } = require("./src/modules/qqbot/plugins/bangDream/application/bangdream-application.service");
|
||||
|
||||
(async () => {
|
||||
const renderer = new QqbotBangDreamRendererService(new ConfigService({}), undefined);
|
||||
|
||||
312
scripts/qqbot-plugin/cli.ts
Normal file
@ -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 { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { MinioModule } from 'nestjs-minio-client';
|
||||
import { MinioClientModule } from './minio/minio.module';
|
||||
import {
|
||||
ApiRequestLogInterceptor,
|
||||
ApiExceptionFilter,
|
||||
@ -14,10 +13,12 @@ import {
|
||||
createPinoLoggerParams,
|
||||
SaveBodyInterceptor,
|
||||
} from './common';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { BlogModule } from './blog/blog.module';
|
||||
import { WordpressModule } from './wordpress/wordpress.module';
|
||||
import { QqbotModule } from './qqbot/qqbot.module';
|
||||
import { AdminModule } from './modules/admin/admin.module';
|
||||
import { AssetModule } from './modules/asset/asset.module';
|
||||
import { BlogContentModule } from './modules/blog/blog-content.module';
|
||||
import { QqbotCoreModule } from './modules/qqbot/core/qqbot-core.module';
|
||||
import { QqbotPluginPlatformModule } from './modules/qqbot/plugin-platform/plugin-platform.module';
|
||||
import { WordpressMirrorModule } from './modules/wordpress/wordpress-mirror.module';
|
||||
import { RuntimeModule } from './runtime';
|
||||
|
||||
@Module({
|
||||
@ -43,7 +44,10 @@ import { RuntimeModule } from './runtime';
|
||||
password: configService.get('DB_PASSWORD'),
|
||||
database: configService.get('DB_DATABASE'),
|
||||
synchronize: configService.get<string>('DB_SYNC') === 'true',
|
||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||
entities: [
|
||||
__dirname + '/**/*.entity{.ts,.js}',
|
||||
__dirname + '/**/*.entities{.ts,.js}',
|
||||
],
|
||||
subscribers: [__dirname + '/**/*.subscriber{.ts,.js}'],
|
||||
};
|
||||
},
|
||||
@ -63,13 +67,14 @@ import { RuntimeModule } from './runtime';
|
||||
},
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
MinioClientModule,
|
||||
CommonModule,
|
||||
RuntimeModule,
|
||||
AdminModule,
|
||||
BlogModule,
|
||||
WordpressModule,
|
||||
QqbotModule,
|
||||
BlogContentModule,
|
||||
WordpressMirrorModule,
|
||||
AssetModule,
|
||||
QqbotCoreModule,
|
||||
QqbotPluginPlatformModule,
|
||||
],
|
||||
providers: [
|
||||
AppService,
|
||||
|
||||
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,
|
||||
QqbotBangDreamCommandOutput,
|
||||
QqbotBangDreamOperationKey,
|
||||
} from '@/qqbot/plugins/bangDream/qqbot-bangdream.types';
|
||||
import { waitForMainDataReady } from '@/qqbot/plugins/bangDream/shared/main-data-store';
|
||||
} from '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.types';
|
||||
import { waitForMainDataReady } from '@/modules/qqbot/plugins/bangDream/shared/main-data-store';
|
||||
import {
|
||||
createTsuguLogHook,
|
||||
TsuguHookRegistry,
|
||||
} from '@/qqbot/plugins/bangDream/hook/hook-registry';
|
||||
import { getBangDreamOperationDefinition } from '@/qqbot/plugins/bangDream/registry/operation-registry';
|
||||
import { TsuguOperationPipeline } from '@/qqbot/plugins/bangDream/application/operation-pipeline';
|
||||
import { QqbotBangDreamRendererService } from '@/qqbot/plugins/bangDream/application/bangdream-renderer.facade';
|
||||
} from '@/modules/qqbot/plugins/bangDream/hook/hook-registry';
|
||||
import { getBangDreamOperationDefinition } from '@/modules/qqbot/plugins/bangDream/registry/operation-registry';
|
||||
import { TsuguOperationPipeline } from '@/modules/qqbot/plugins/bangDream/application/operation-pipeline';
|
||||
import { QqbotBangDreamRendererService } from '@/modules/qqbot/plugins/bangDream/application/bangdream-renderer.facade';
|
||||
|
||||
@Injectable()
|
||||
export class TsuguApplicationService implements OnApplicationBootstrap {
|
||||
@ -4,8 +4,8 @@ import type {
|
||||
QqbotBangDreamOperationKey,
|
||||
QqbotBangDreamSongSearchInput,
|
||||
QqbotBangDreamSongSummary,
|
||||
} from '@/qqbot/plugins/bangDream/qqbot-bangdream.types';
|
||||
import { TsuguApplicationService } from '@/qqbot/plugins/bangDream/application/bangdream-application.service';
|
||||
} from '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.types';
|
||||
import { TsuguApplicationService } from '@/modules/qqbot/plugins/bangDream/application/bangdream-application.service';
|
||||
|
||||
@Injectable()
|
||||
export class QqbotBangDreamClientService {
|
||||
@ -1,54 +1,59 @@
|
||||
import { Injectable, Optional } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DictService } from '@/admin/dict/dict.service';
|
||||
import { Card } from '@/qqbot/plugins/bangDream/card/card.model';
|
||||
import { getPresentEvent } from '@/qqbot/plugins/bangDream/event/event.model';
|
||||
import { Gacha, getPresentGachaList } from '@/qqbot/plugins/bangDream/gacha/gacha.model';
|
||||
import { Card } from '@/modules/qqbot/plugins/bangDream/card/card.model';
|
||||
import { getPresentEvent } from '@/modules/qqbot/plugins/bangDream/event/event.model';
|
||||
import {
|
||||
Gacha,
|
||||
getPresentGachaList,
|
||||
} from '@/modules/qqbot/plugins/bangDream/gacha/gacha.model';
|
||||
import {
|
||||
BANGDREAM_GACHA_DEFAULT_SPIN_COUNT,
|
||||
isBirthdayGachaType,
|
||||
} from '@/qqbot/plugins/bangDream/policy/gacha.policy';
|
||||
import { Server } from '@/qqbot/plugins/bangDream/catalog/server.model';
|
||||
import { Song } from '@/qqbot/plugins/bangDream/song/song.model';
|
||||
import mainAPI, { waitForMainDataReady } from '@/qqbot/plugins/bangDream/shared/main-data-store';
|
||||
} from '@/modules/qqbot/plugins/bangDream/policy/gacha.policy';
|
||||
import { Server } from '@/modules/qqbot/plugins/bangDream/catalog/server.model';
|
||||
import { Song } from '@/modules/qqbot/plugins/bangDream/song/song.model';
|
||||
import mainAPI, {
|
||||
waitForMainDataReady,
|
||||
} from '@/modules/qqbot/plugins/bangDream/shared/main-data-store';
|
||||
import {
|
||||
fuzzySearch,
|
||||
type FuzzySearchResult,
|
||||
} from '@/qqbot/plugins/bangDream/search/fuzzy-search';
|
||||
import { drawCardDetail } from '@/qqbot/plugins/bangDream/card/card-detail.renderer';
|
||||
import { drawCardList } from '@/qqbot/plugins/bangDream/card/card-search.renderer';
|
||||
import { drawCharacterDetail } from '@/qqbot/plugins/bangDream/character/character-detail.renderer';
|
||||
import { drawCharacterList } from '@/qqbot/plugins/bangDream/character/character-search.renderer';
|
||||
import { drawCutoffAll } from '@/qqbot/plugins/bangDream/cutoff/cutoff-all.renderer';
|
||||
import { drawCutoffDetail } from '@/qqbot/plugins/bangDream/cutoff/cutoff-detail.renderer';
|
||||
import { drawCutoffEventTop } from '@/qqbot/plugins/bangDream/cutoff/cutoff-event-top.renderer';
|
||||
import { drawCutoffListOfRecentEvent } from '@/qqbot/plugins/bangDream/cutoff/cutoff-recent.renderer';
|
||||
import { drawEventDetail } from '@/qqbot/plugins/bangDream/event/event-detail.renderer';
|
||||
import { drawEventList } from '@/qqbot/plugins/bangDream/event/event-search.renderer';
|
||||
import { drawEventStage } from '@/qqbot/plugins/bangDream/event/event-stage.renderer';
|
||||
import { drawGachaDetail } from '@/qqbot/plugins/bangDream/gacha/gacha-detail.renderer';
|
||||
import { drawRandomGacha } from '@/qqbot/plugins/bangDream/gacha/gacha-simulate.renderer';
|
||||
import { drawPlayerDetail } from '@/qqbot/plugins/bangDream/player/player-detail.renderer';
|
||||
import { drawSongChart } from '@/qqbot/plugins/bangDream/song/song-chart.renderer';
|
||||
import { drawSongDetail } from '@/qqbot/plugins/bangDream/song/song-detail.renderer';
|
||||
import { drawSongList } from '@/qqbot/plugins/bangDream/song/song-search.renderer';
|
||||
import { drawSongMetaList } from '@/qqbot/plugins/bangDream/song/song-meta.renderer';
|
||||
import { drawSongRandom } from '@/qqbot/plugins/bangDream/song/song-random.renderer';
|
||||
} from '@/modules/qqbot/plugins/bangDream/search/fuzzy-search';
|
||||
import { drawCardDetail } from '@/modules/qqbot/plugins/bangDream/card/card-detail.renderer';
|
||||
import { drawCardList } from '@/modules/qqbot/plugins/bangDream/card/card-search.renderer';
|
||||
import { drawCharacterDetail } from '@/modules/qqbot/plugins/bangDream/character/character-detail.renderer';
|
||||
import { drawCharacterList } from '@/modules/qqbot/plugins/bangDream/character/character-search.renderer';
|
||||
import { drawCutoffAll } from '@/modules/qqbot/plugins/bangDream/cutoff/cutoff-all.renderer';
|
||||
import { drawCutoffDetail } from '@/modules/qqbot/plugins/bangDream/cutoff/cutoff-detail.renderer';
|
||||
import { drawCutoffEventTop } from '@/modules/qqbot/plugins/bangDream/cutoff/cutoff-event-top.renderer';
|
||||
import { drawCutoffListOfRecentEvent } from '@/modules/qqbot/plugins/bangDream/cutoff/cutoff-recent.renderer';
|
||||
import { drawEventDetail } from '@/modules/qqbot/plugins/bangDream/event/event-detail.renderer';
|
||||
import { drawEventList } from '@/modules/qqbot/plugins/bangDream/event/event-search.renderer';
|
||||
import { drawEventStage } from '@/modules/qqbot/plugins/bangDream/event/event-stage.renderer';
|
||||
import { drawGachaDetail } from '@/modules/qqbot/plugins/bangDream/gacha/gacha-detail.renderer';
|
||||
import { drawRandomGacha } from '@/modules/qqbot/plugins/bangDream/gacha/gacha-simulate.renderer';
|
||||
import { drawPlayerDetail } from '@/modules/qqbot/plugins/bangDream/player/player-detail.renderer';
|
||||
import { drawSongChart } from '@/modules/qqbot/plugins/bangDream/song/song-chart.renderer';
|
||||
import { drawSongDetail } from '@/modules/qqbot/plugins/bangDream/song/song-detail.renderer';
|
||||
import { drawSongList } from '@/modules/qqbot/plugins/bangDream/song/song-search.renderer';
|
||||
import { drawSongMetaList } from '@/modules/qqbot/plugins/bangDream/song/song-meta.renderer';
|
||||
import { drawSongRandom } from '@/modules/qqbot/plugins/bangDream/song/song-random.renderer';
|
||||
import {
|
||||
BangDreamDictionaryLoader,
|
||||
type BangDreamDictionaryItem,
|
||||
} from '@/qqbot/plugins/bangDream/dictionary/dictionary-loader';
|
||||
import type { QqbotBangDreamOperationHandlerName } from '@/qqbot/plugins/bangDream/registry/operation-registry';
|
||||
} from '@/modules/qqbot/plugins/bangDream/dictionary/dictionary-loader';
|
||||
import type { QqbotBangDreamOperationHandlerName } from '@/modules/qqbot/plugins/bangDream/registry/operation-registry';
|
||||
import {
|
||||
BANGDREAM_TSUGU_ENV_KEYS,
|
||||
normalizeBangDreamBoolean,
|
||||
splitBangDreamOptionList,
|
||||
} from '@/qqbot/plugins/bangDream/config/runtime-options';
|
||||
} from '@/modules/qqbot/plugins/bangDream/config/runtime-options';
|
||||
import type {
|
||||
QqbotBangDreamCommandInput,
|
||||
QqbotBangDreamCommandOutput,
|
||||
QqbotBangDreamOperationKey,
|
||||
} from '@/qqbot/plugins/bangDream/qqbot-bangdream.types';
|
||||
} from '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.types';
|
||||
|
||||
const SOURCE_NAME = 'Tsugu BangDream Bot 内置源码';
|
||||
|
||||
@ -2,15 +2,15 @@ import type {
|
||||
QqbotBangDreamCommandInput,
|
||||
QqbotBangDreamCommandOutput,
|
||||
QqbotBangDreamOperationKey,
|
||||
} from '@/qqbot/plugins/bangDream/qqbot-bangdream.types';
|
||||
} from '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.types';
|
||||
import type {
|
||||
QqbotBangDreamOperationHandlerName,
|
||||
TsuguOperationDefinition,
|
||||
} from '@/qqbot/plugins/bangDream/registry/operation-registry';
|
||||
} from '@/modules/qqbot/plugins/bangDream/registry/operation-registry';
|
||||
import {
|
||||
createTsuguHookContext,
|
||||
type TsuguHookRegistry,
|
||||
} from '@/qqbot/plugins/bangDream/hook/hook-registry';
|
||||
} from '@/modules/qqbot/plugins/bangDream/hook/hook-registry';
|
||||
|
||||
export type TsuguOperationPipelineOptions = {
|
||||
executeHandler: (
|
||||
|
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 |