feat: 重构时间序列化装饰器

This commit is contained in:
sunlei 2026-06-12 16:32:35 +08:00
parent ed5b1f23bd
commit da5e5f27e2
42 changed files with 1031 additions and 846 deletions

490
API.md
View File

@ -47,24 +47,24 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
### ID 与时间
- 后台主键使用 Snowflake 数字 ID接口按字符串返回避免 JavaScript 长整型精度丢失。
- DTO/Entity 需要后端格式化的时间字段使用 `@FormatDateTime()`,输出格式为 `YYYY-MM-DD HH:mm:ss`
- 后端格式化时间字段统一使用 `KtDateTime extends Date`Entity 通过 `@KtDateTimeColumn(format)`、`@KtCreateDateColumn(format)`、`@KtUpdateDateColumn(format)` 在 TypeORM hydrate 边界转换DTO/外部数据源通过 `@KtDateTimeField(format)` + `transformKtDateTimeFields()` 转换。默认输出 `YYYY-MM-DD HH:mm:ss`,可在装饰器中传入格式字符串;响应包装不做递归遍历
- `POST */save` 默认会删除请求体里的 `id`,防止新增接口误用前端主键。
## 环境变量分组
| 分组 | 关键变量 |
| --- | --- |
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET` |
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD` |
| Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_QUERY_SELECTOR` |
| QQBot | `QQBOT_ENABLED`、`QQBOT_REVERSE_WS_PATH`、`QQBOT_REVERSE_WS_TOKEN`、`QQBOT_EVENT_BUS`、`QQBOT_SEND_*`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*` |
| NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` |
| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` |
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_DEFAULT_WORLD` |
| FFLogs | `FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
| 分组 | 关键变量 |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET` |
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD` |
| Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_QUERY_SELECTOR` |
| QQBot | `QQBOT_ENABLED`、`QQBOT_REVERSE_WS_PATH`、`QQBOT_REVERSE_WS_TOKEN`、`QQBOT_EVENT_BUS`、`QQBOT_SEND_*`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*` |
| NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` |
| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` |
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_DEFAULT_WORLD` |
| FFLogs | `FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
真实密码、Token、OAuth secret 和生产 env 不提交到 Git。
@ -72,103 +72,103 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
### Auth / User
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `POST` | `/auth/login` | 后台登录,返回 accessToken、用户信息和 WordPress 自动登录状态,并写入 httpOnly cookie |
| `POST` | `/auth/refresh` | 通过 refresh token cookie 刷新 accessToken |
| `POST` | `/auth/logout` | 清理 Admin 与 WordPress 登录 cookie |
| `GET` | `/auth/codes` | 获取当前用户按钮权限码 |
| `GET` | `/user/info` | 获取当前用户信息 |
| 方法 | 路径 | 说明 |
| ------ | --------------- | ------------------------------------------------------------------------------------- |
| `POST` | `/auth/login` | 后台登录,返回 accessToken、用户信息和 WordPress 自动登录状态,并写入 httpOnly cookie |
| `POST` | `/auth/refresh` | 通过 refresh token cookie 刷新 accessToken |
| `POST` | `/auth/logout` | 清理 Admin 与 WordPress 登录 cookie |
| `GET` | `/auth/codes` | 获取当前用户按钮权限码 |
| `GET` | `/user/info` | 获取当前用户信息 |
`/auth/login` 会尝试用 env 中的 WordPress 管理员账号建立 WordPress 登录态。WordPress 不可用时Admin 主登录仍成功,返回 `wordpressAuth=null`、`wordpressAvailable=false`,菜单和权限码会过滤 Blog 管理入口。
### Menu / Role / Dept / User Manage
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/menu/all` | 当前用户菜单 |
| `GET` | `/system/menu/list` | 系统菜单树 |
| `GET` | `/system/menu/name-exists` | 菜单 name 重名校验 |
| `GET` | `/system/menu/path-exists` | 菜单 path 重名校验 |
| `POST` | `/system/menu` | 新增菜单 |
| `PUT` | `/system/menu/:id` | 更新菜单 |
| `DELETE` | `/system/menu/:id` | 删除菜单及子菜单 |
| `GET` | `/system/role/list` | 角色分页 |
| `POST` | `/system/role` | 新增角色 |
| `PUT` | `/system/role/:id` | 更新角色 |
| `DELETE` | `/system/role/:id` | 删除角色 |
| `GET` | `/system/dept/list` | 部门树 |
| `POST` | `/system/dept` | 新增部门 |
| `PUT` | `/system/dept/:id` | 更新部门 |
| `DELETE` | `/system/dept/:id` | 删除部门 |
| `GET` | `/system/user/list` | 用户分页 |
| `POST` | `/system/user` | 新增用户 |
| `PUT` | `/system/user/:id` | 更新用户 |
| `DELETE` | `/system/user/:id` | 删除用户 |
| 方法 | 路径 | 说明 |
| -------- | -------------------------- | ------------------ |
| `GET` | `/menu/all` | 当前用户菜单 |
| `GET` | `/system/menu/list` | 系统菜单树 |
| `GET` | `/system/menu/name-exists` | 菜单 name 重名校验 |
| `GET` | `/system/menu/path-exists` | 菜单 path 重名校验 |
| `POST` | `/system/menu` | 新增菜单 |
| `PUT` | `/system/menu/:id` | 更新菜单 |
| `DELETE` | `/system/menu/:id` | 删除菜单及子菜单 |
| `GET` | `/system/role/list` | 角色分页 |
| `POST` | `/system/role` | 新增角色 |
| `PUT` | `/system/role/:id` | 更新角色 |
| `DELETE` | `/system/role/:id` | 删除角色 |
| `GET` | `/system/dept/list` | 部门树 |
| `POST` | `/system/dept` | 新增部门 |
| `PUT` | `/system/dept/:id` | 更新部门 |
| `DELETE` | `/system/dept/:id` | 删除部门 |
| `GET` | `/system/user/list` | 用户分页 |
| `POST` | `/system/user` | 新增用户 |
| `PUT` | `/system/user/:id` | 更新用户 |
| `DELETE` | `/system/user/:id` | 删除用户 |
### Dict
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/dict/list` | 字典项分页,支持 `dictCode`、`keyword`、`label`、`value`、`childrenCode`、`status` |
| `GET` | `/dict/tree` | 兼容树形字典视图 |
| `GET` | `/dict/groups` | 字典编码分组列表,适合左右表左侧分组 |
| `GET` | `/dict/codes` | 字典编码选项 |
| `GET` | `/dict/getDictByKey` | 按 `dictKey` 获取启用字典项 |
| `GET` | `/dict/getComponentDictByType` | 按组件一级类型查二级类型 |
| `POST` | `/dict/save` | 新增字典项 |
| `POST` | `/dict/update` | 更新字典项 |
| `DELETE` | `/dict/:id` | 物理删除字典项 |
| `POST` | `/dict/toggle` | 启停字典项 |
| 方法 | 路径 | 说明 |
| -------- | ------------------------------ | ---------------------------------------------------------------------------------- |
| `GET` | `/dict/list` | 字典项分页,支持 `dictCode`、`keyword`、`label`、`value`、`childrenCode`、`status` |
| `GET` | `/dict/tree` | 兼容树形字典视图 |
| `GET` | `/dict/groups` | 字典编码分组列表,适合左右表左侧分组 |
| `GET` | `/dict/codes` | 字典编码选项 |
| `GET` | `/dict/getDictByKey` | 按 `dictKey` 获取启用字典项 |
| `GET` | `/dict/getComponentDictByType` | 按组件一级类型查二级类型 |
| `POST` | `/dict/save` | 新增字典项 |
| `POST` | `/dict/update` | 更新字典项 |
| `DELETE` | `/dict/:id` | 物理删除字典项 |
| `POST` | `/dict/toggle` | 启停字典项 |
字典核心字段:
| 字段 | 说明 |
| --- | --- |
| `dictCode` | 字典分组,例如 `COMPONENT_TYPE`、`BANGDREAM_SERVER_ALIAS` |
| `label` | 展示文本 |
| `value` | 字典值 |
| `childrenCode` | 关联子分组编码 |
| `sort` | 排序 |
| `status` | `1` 启用 |
| 字段 | 说明 |
| -------------- | --------------------------------------------------------- |
| `dictCode` | 字典分组,例如 `COMPONENT_TYPE`、`BANGDREAM_SERVER_ALIAS` |
| `label` | 展示文本 |
| `value` | 字典值 |
| `childrenCode` | 关联子分组编码 |
| `sort` | 排序 |
| `status` | `1` 启用 |
### Component
组件接口保持 `/component/*` 路径兼容,但数据表为 `admin_component`
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/component/allList` | 全量组件 |
| `GET` | `/component/list` | 组件分页,支持 `pageNo`、`pageSize`、`name`、`type`、`componentType` |
| `GET` | `/component/detail?id=` | 组件详情 |
| `POST` | `/component/save` | 新增组件 |
| `POST` | `/component/update` | 更新组件 |
| `POST` | `/component/remove?id=` | 逻辑删除组件 |
| 方法 | 路径 | 说明 |
| ------ | ----------------------- | -------------------------------------------------------------------- |
| `GET` | `/component/allList` | 全量组件 |
| `GET` | `/component/list` | 组件分页,支持 `pageNo`、`pageSize`、`name`、`type`、`componentType` |
| `GET` | `/component/detail?id=` | 组件详情 |
| `POST` | `/component/save` | 新增组件 |
| `POST` | `/component/update` | 更新组件 |
| `POST` | `/component/remove?id=` | 逻辑删除组件 |
### Timezone / Upload / Demo
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/timezone/getTimezoneOptions` | 时区选项 |
| `GET` | `/timezone/getTimezone` | 当前用户时区 |
| `POST` | `/timezone/setTimezone` | 设置当前用户时区 |
| `POST` | `/upload` | Vben 上传适配,实际写入 MinIO |
| `GET` | `/table/list` | Vben 示例表格 |
| `GET` | `/status` | 状态码测试 |
| `GET` | `/demo/bigint` | BigInt JSON 测试 |
| `GET` | `/test` | GET 测试 |
| `POST` | `/test` | POST 测试 |
| 方法 | 路径 | 说明 |
| ------ | ------------------------------ | ----------------------------- |
| `GET` | `/timezone/getTimezoneOptions` | 时区选项 |
| `GET` | `/timezone/getTimezone` | 当前用户时区 |
| `POST` | `/timezone/setTimezone` | 设置当前用户时区 |
| `POST` | `/upload` | Vben 上传适配,实际写入 MinIO |
| `GET` | `/table/list` | Vben 示例表格 |
| `GET` | `/status` | 状态码测试 |
| `GET` | `/demo/bigint` | BigInt JSON 测试 |
| `GET` | `/test` | GET 测试 |
| `POST` | `/test` | POST 测试 |
## 系统日志
后端通过 `nestjs-pino` 输出结构化日志。配置 Loki 后Admin 日志页面通过后端代理查询,不直连 Loki。
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/system/logs` | 日志分页,支持 `level`、`keyword`、`context`、`path`、`requestId`、`startTime`、`endTime`、`rangeMinutes` |
| `GET` | `/system/logs/summary` | 按级别统计 |
| `GET` | `/system/logs/levels` | 日志级别选项 |
| `GET` | `/system/logs/status` | Loki 查询配置状态 |
| 方法 | 路径 | 说明 |
| ----- | ---------------------- | --------------------------------------------------------------------------------------------------------- |
| `GET` | `/system/logs` | 日志分页,支持 `level`、`keyword`、`context`、`path`、`requestId`、`startTime`、`endTime`、`rangeMinutes` |
| `GET` | `/system/logs/summary` | 按级别统计 |
| `GET` | `/system/logs/levels` | 日志级别选项 |
| `GET` | `/system/logs/status` | Loki 查询配置状态 |
日志行包含 `timestamp`、`level`、`message`、`method`、`path`、`statusCode`、`durationMs`、`requestId`、`raw` 等字段。
@ -176,13 +176,13 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
站内信用于承接运行期事件,不再作为人工公告入口。后端在接口 5xx、QQBot OneBot 下线 notice、NapCat 容器日志检测到账户离线时自动生成或聚合一条通知,默认通知 `super` 角色;站内信接口在服务端也强制 `super` 角色访问。相同 `dedupeKey` 的事件通过 `active_dedupe_key` 唯一索引聚合,会累加 `occurrenceCount`,刷新 `lastSeenAt`,并把状态重新置为未处理。运行期通知会按表字段长度归一化 `title`、`dedupeKey`、`source`、`eventType` 和 `notifyRoleCode`,长 `dedupeKey` 会保留稳定 hash 后缀,避免长路径接口错误丢通知。
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/system/notice/list` | 日志级站内信分页列表,支持 `keyword`、`severity`、`source`、`eventType`、`status`、`isTop`、`notifyRoleCode`、`notifyUsers`、`pageNo`、`pageSize` |
| `GET` | `/system/notice/detail/:id` | 查询站内信详情 |
| `DELETE` | `/system/notice/:id` | 删除站内信(逻辑删除) |
| `POST` | `/system/notice/toggle` | 标记处理或重新打开(`id`、`status``1` 未处理,`0` 已处理) |
| `POST` | `/system/notice/top` | 切换置顶(`id`、`isTop` |
| 方法 | 路径 | 说明 |
| -------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET` | `/system/notice/list` | 日志级站内信分页列表,支持 `keyword`、`severity`、`source`、`eventType`、`status`、`isTop`、`notifyRoleCode`、`notifyUsers`、`pageNo`、`pageSize` |
| `GET` | `/system/notice/detail/:id` | 查询站内信详情 |
| `DELETE` | `/system/notice/:id` | 删除站内信(逻辑删除) |
| `POST` | `/system/notice/toggle` | 标记处理或重新打开(`id`、`status``1` 未处理,`0` 已处理) |
| `POST` | `/system/notice/top` | 切换置顶(`id`、`isTop` |
返回字段包含 `severity`、`source`、`eventType`、`dedupeKey`、`occurrenceCount`、`notifyRoleCode`、`metadata`、`firstSeenAt`、`lastSeenAt`。后端不暴露人工 `save/update` 入口。
@ -192,18 +192,18 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
### Blog Article
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/blog/article/public/list` | 公开文章分页 |
| `GET` | `/blog/article/public/detail` | 公开文章详情,支持 id/slug |
| `GET` | `/blog/article/list` | 后台文章分页 |
| `GET` | `/blog/article/detail` | 后台文章详情 |
| `POST` | `/blog/article/save` | 新增文章 |
| `POST` | `/blog/article/update` | 更新文章 |
| `POST` | `/blog/article/remove` | 删除文章 |
| `GET` | `/blog/article/category-options` | 文章分类选项 |
| `GET` | `/blog/article/tag-options` | 文章标签选项 |
| `POST` | `/blog/article/import-wordpress` | 从 WordPress 导入文章 |
| 方法 | 路径 | 说明 |
| ------ | -------------------------------- | -------------------------- |
| `GET` | `/blog/article/public/list` | 公开文章分页 |
| `GET` | `/blog/article/public/detail` | 公开文章详情,支持 id/slug |
| `GET` | `/blog/article/list` | 后台文章分页 |
| `GET` | `/blog/article/detail` | 后台文章详情 |
| `POST` | `/blog/article/save` | 新增文章 |
| `POST` | `/blog/article/update` | 更新文章 |
| `POST` | `/blog/article/remove` | 删除文章 |
| `GET` | `/blog/article/category-options` | 文章分类选项 |
| `GET` | `/blog/article/tag-options` | 文章标签选项 |
| `POST` | `/blog/article/import-wordpress` | 从 WordPress 导入文章 |
文章 body 常用字段:
@ -222,70 +222,70 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
### Blog Category / Tag / Theme
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/blog/category/list` | 本地分类分页 |
| `GET` | `/blog/category/detail` | 本地分类详情 |
| `POST` | `/blog/category/save` | 新增分类 |
| `POST` | `/blog/category/update` | 更新分类 |
| `POST` | `/blog/category/remove` | 删除分类 |
| `GET` | `/blog/tag/list` | 本地标签分页 |
| `GET` | `/blog/tag/detail` | 本地标签详情 |
| `POST` | `/blog/tag/save` | 新增标签 |
| `POST` | `/blog/tag/update` | 更新标签 |
| `POST` | `/blog/tag/remove` | 删除标签 |
| `GET` | `/blog/term/options` | 分类/标签选项 |
| `GET` | `/blog/theme/config` | 获取 Argon 主题配置 |
| `POST` | `/blog/theme/save` | 保存本地主题配置 |
| 方法 | 路径 | 说明 |
| ------ | ------------------------------ | ------------------------- |
| `GET` | `/blog/category/list` | 本地分类分页 |
| `GET` | `/blog/category/detail` | 本地分类详情 |
| `POST` | `/blog/category/save` | 新增分类 |
| `POST` | `/blog/category/update` | 更新分类 |
| `POST` | `/blog/category/remove` | 删除分类 |
| `GET` | `/blog/tag/list` | 本地标签分页 |
| `GET` | `/blog/tag/detail` | 本地标签详情 |
| `POST` | `/blog/tag/save` | 新增标签 |
| `POST` | `/blog/tag/update` | 更新标签 |
| `POST` | `/blog/tag/remove` | 删除标签 |
| `GET` | `/blog/term/options` | 分类/标签选项 |
| `GET` | `/blog/theme/config` | 获取 Argon 主题配置 |
| `POST` | `/blog/theme/save` | 保存本地主题配置 |
| `POST` | `/blog/theme/import-wordpress` | 从 WordPress 导入主题配置 |
## WordPress 代理
`/wordpress/*` 需要 Admin 登录态和 WordPress 登录态。后端优先使用 `kt_wordpress_auth` httpOnly cookie也支持显式透传 WordPress 认证 header。
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `POST` | `/wordpress/auth/login` | 使用 env 管理员账号登录 WordPress 并写入 cookie |
| `POST` | `/wordpress/auth/logout` | 清理 WordPress cookie |
| `GET` | `/wordpress/auth/check` | 校验 WordPress 登录态 |
| `GET` | `/wordpress/theme/config` | 读取 WordPress Argon 主题配置 |
| 方法 | 路径 | 说明 |
| ------ | ------------------------- | ----------------------------------------------- |
| `POST` | `/wordpress/auth/login` | 使用 env 管理员账号登录 WordPress 并写入 cookie |
| `POST` | `/wordpress/auth/logout` | 清理 WordPress cookie |
| `GET` | `/wordpress/auth/check` | 校验 WordPress 登录态 |
| `GET` | `/wordpress/theme/config` | 读取 WordPress Argon 主题配置 |
### WordPress Article / Tag / Category
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/wordpress/article/public/list` | 公开文章列表代理 |
| `GET` | `/wordpress/article/public/detail` | 公开文章详情代理 |
| `GET` | `/wordpress/article/list` | WordPress 文章分页 |
| `GET` | `/wordpress/article/detail` | WordPress 文章详情 |
| `POST` | `/wordpress/article/save` | 新增 WordPress 文章 |
| `POST` | `/wordpress/article/update` | 更新 WordPress 文章 |
| `POST` | `/wordpress/article/remove` | 删除 WordPress 文章 |
| `GET` | `/wordpress/tag/list` | 标签分页 |
| `GET` | `/wordpress/tag/detail` | 标签详情 |
| `POST` | `/wordpress/tag/save` | 新增标签 |
| `POST` | `/wordpress/tag/update` | 更新标签 |
| `POST` | `/wordpress/tag/remove` | 删除标签 |
| `GET` | `/wordpress/category/list` | 分类分页 |
| `GET` | `/wordpress/category/detail` | 分类详情 |
| `POST` | `/wordpress/category/save` | 新增分类 |
| `POST` | `/wordpress/category/update` | 更新分类 |
| `POST` | `/wordpress/category/remove` | 删除分类 |
| 方法 | 路径 | 说明 |
| ------ | ---------------------------------- | ------------------- |
| `GET` | `/wordpress/article/public/list` | 公开文章列表代理 |
| `GET` | `/wordpress/article/public/detail` | 公开文章详情代理 |
| `GET` | `/wordpress/article/list` | WordPress 文章分页 |
| `GET` | `/wordpress/article/detail` | WordPress 文章详情 |
| `POST` | `/wordpress/article/save` | 新增 WordPress 文章 |
| `POST` | `/wordpress/article/update` | 更新 WordPress 文章 |
| `POST` | `/wordpress/article/remove` | 删除 WordPress 文章 |
| `GET` | `/wordpress/tag/list` | 标签分页 |
| `GET` | `/wordpress/tag/detail` | 标签详情 |
| `POST` | `/wordpress/tag/save` | 新增标签 |
| `POST` | `/wordpress/tag/update` | 更新标签 |
| `POST` | `/wordpress/tag/remove` | 删除标签 |
| `GET` | `/wordpress/category/list` | 分类分页 |
| `GET` | `/wordpress/category/detail` | 分类详情 |
| `POST` | `/wordpress/category/save` | 新增分类 |
| `POST` | `/wordpress/category/update` | 更新分类 |
| `POST` | `/wordpress/category/remove` | 删除分类 |
WordPress rewrite 未开启导致 `/wp-json/*` 返回 404 时,后端会回退到 `?rest_route=/...`
## MinIO
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/minio/check` | 检查连接和 bucket |
| `POST` | `/minio/bucket` | 创建 bucket |
| `POST` | `/minio/upload` | 上传文件,`multipart/form-data` |
| `GET` | `/minio/list` | 文件列表 |
| `GET` | `/minio/url` | 临时访问 URL |
| `GET` | `/minio/resource-proxy` | 代理读取资源 |
| `GET` | `/minio/download` | 下载文件流 |
| `DELETE` | `/minio/remove` | 删除文件 |
| 方法 | 路径 | 说明 |
| -------- | ----------------------- | ------------------------------- |
| `GET` | `/minio/check` | 检查连接和 bucket |
| `POST` | `/minio/bucket` | 创建 bucket |
| `POST` | `/minio/upload` | 上传文件,`multipart/form-data` |
| `GET` | `/minio/list` | 文件列表 |
| `GET` | `/minio/url` | 临时访问 URL |
| `GET` | `/minio/resource-proxy` | 代理读取资源 |
| `GET` | `/minio/download` | 下载文件流 |
| `DELETE` | `/minio/remove` | 删除文件 |
`bucketName` 不传时使用 `MINIO_BUCKET`
@ -295,24 +295,24 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
### Account / Scan Login
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/qqbot/account/list` | QQBot 账号分页 |
| `GET` | `/qqbot/account/enabled` | 启用账号列表 |
| `POST` | `/qqbot/account/save` | 手动新增账号 |
| `POST` | `/qqbot/account/update` | 更新账号 |
| `POST` | `/qqbot/account/scan/create` | 扫码新增账号,创建登录会话 |
| `POST` | `/qqbot/account/scan/refresh?id=` | 对已有账号刷新登录态 |
| `GET` | `/qqbot/account/scan/status?sessionId=` | 查询扫码会话状态 |
| `GET` | `/qqbot/account/scan/events?sessionId=` | SSE 订阅扫码进度 |
| `POST` | `/qqbot/account/scan/qrcode/refresh?sessionId=` | 刷新当前会话二维码 |
| `POST` | `/qqbot/account/scan/cancel?sessionId=` | 取消扫码会话 |
| `POST` | `/qqbot/account/delete?id=` | 删除账号并断开 WS |
| `POST` | `/qqbot/account/kick?selfId=` | 断开反向 WS 会话 |
| `POST` | `/qqbot/account/bind/command` | 绑定账号和在线命令 |
| `POST` | `/qqbot/account/unbind/command` | 解绑账号和在线命令 |
| `POST` | `/qqbot/account/bind/rule` | 绑定账号和自动回复规则 |
| `POST` | `/qqbot/account/unbind/rule` | 解绑账号和自动回复规则 |
| 方法 | 路径 | 说明 |
| ------ | ----------------------------------------------- | -------------------------- |
| `GET` | `/qqbot/account/list` | QQBot 账号分页 |
| `GET` | `/qqbot/account/enabled` | 启用账号列表 |
| `POST` | `/qqbot/account/save` | 手动新增账号 |
| `POST` | `/qqbot/account/update` | 更新账号 |
| `POST` | `/qqbot/account/scan/create` | 扫码新增账号,创建登录会话 |
| `POST` | `/qqbot/account/scan/refresh?id=` | 对已有账号刷新登录态 |
| `GET` | `/qqbot/account/scan/status?sessionId=` | 查询扫码会话状态 |
| `GET` | `/qqbot/account/scan/events?sessionId=` | SSE 订阅扫码进度 |
| `POST` | `/qqbot/account/scan/qrcode/refresh?sessionId=` | 刷新当前会话二维码 |
| `POST` | `/qqbot/account/scan/cancel?sessionId=` | 取消扫码会话 |
| `POST` | `/qqbot/account/delete?id=` | 删除账号并断开 WS |
| `POST` | `/qqbot/account/kick?selfId=` | 断开反向 WS 会话 |
| `POST` | `/qqbot/account/bind/command` | 绑定账号和在线命令 |
| `POST` | `/qqbot/account/unbind/command` | 解绑账号和在线命令 |
| `POST` | `/qqbot/account/bind/rule` | 绑定账号和自动回复规则 |
| `POST` | `/qqbot/account/unbind/rule` | 解绑账号和自动回复规则 |
扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。
@ -322,29 +322,29 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
### Command / Rule / Permission
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/qqbot/command/list` | 在线命令分页,支持 `pluginKey`、`operationKey`、`selfId`、`enabled` |
| `POST` | `/qqbot/command/save` | 新增在线命令 |
| `POST` | `/qqbot/command/update` | 更新在线命令 |
| `POST` | `/qqbot/command/delete?id=` | 删除在线命令 |
| `POST` | `/qqbot/command/toggle?id=&enabled=` | 启停在线命令 |
| `POST` | `/qqbot/command/test` | 预览测试在线命令 |
| `GET` | `/qqbot/rule/list` | 自动回复规则分页 |
| `POST` | `/qqbot/rule/save` | 新增自动回复规则 |
| `POST` | `/qqbot/rule/update` | 更新自动回复规则 |
| `POST` | `/qqbot/rule/delete?id=` | 删除自动回复规则 |
| `POST` | `/qqbot/rule/toggle?id=&enabled=` | 启停自动回复规则 |
| `GET` | `/qqbot/permission/config` | 权限名单配置 |
| `POST` | `/qqbot/permission/config` | 保存权限名单配置 |
| `GET` | `/qqbot/permission/allowlist` | 白名单分页 |
| `POST` | `/qqbot/permission/allowlist/save` | 新增白名单 |
| `POST` | `/qqbot/permission/allowlist/update` | 更新白名单 |
| `POST` | `/qqbot/permission/allowlist/delete?id=` | 删除白名单 |
| `GET` | `/qqbot/permission/blocklist` | 黑名单分页 |
| `POST` | `/qqbot/permission/blocklist/save` | 新增黑名单 |
| `POST` | `/qqbot/permission/blocklist/update` | 更新黑名单 |
| `POST` | `/qqbot/permission/blocklist/delete?id=` | 删除黑名单 |
| 方法 | 路径 | 说明 |
| ------ | ---------------------------------------- | ------------------------------------------------------------------- |
| `GET` | `/qqbot/command/list` | 在线命令分页,支持 `pluginKey`、`operationKey`、`selfId`、`enabled` |
| `POST` | `/qqbot/command/save` | 新增在线命令 |
| `POST` | `/qqbot/command/update` | 更新在线命令 |
| `POST` | `/qqbot/command/delete?id=` | 删除在线命令 |
| `POST` | `/qqbot/command/toggle?id=&enabled=` | 启停在线命令 |
| `POST` | `/qqbot/command/test` | 预览测试在线命令 |
| `GET` | `/qqbot/rule/list` | 自动回复规则分页 |
| `POST` | `/qqbot/rule/save` | 新增自动回复规则 |
| `POST` | `/qqbot/rule/update` | 更新自动回复规则 |
| `POST` | `/qqbot/rule/delete?id=` | 删除自动回复规则 |
| `POST` | `/qqbot/rule/toggle?id=&enabled=` | 启停自动回复规则 |
| `GET` | `/qqbot/permission/config` | 权限名单配置 |
| `POST` | `/qqbot/permission/config` | 保存权限名单配置 |
| `GET` | `/qqbot/permission/allowlist` | 白名单分页 |
| `POST` | `/qqbot/permission/allowlist/save` | 新增白名单 |
| `POST` | `/qqbot/permission/allowlist/update` | 更新白名单 |
| `POST` | `/qqbot/permission/allowlist/delete?id=` | 删除白名单 |
| `GET` | `/qqbot/permission/blocklist` | 黑名单分页 |
| `POST` | `/qqbot/permission/blocklist/save` | 新增黑名单 |
| `POST` | `/qqbot/permission/blocklist/update` | 更新黑名单 |
| `POST` | `/qqbot/permission/blocklist/delete?id=` | 删除黑名单 |
`/qqbot/command/test` 示例:
@ -363,20 +363,20 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
### Plugin / Dashboard / Send / Message
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/qqbot/plugin/list` | 插件列表,支持 `triggerMode=command/event` |
| `GET` | `/qqbot/plugin/operation/list` | 插件能力列表 |
| `GET` | `/qqbot/plugin/health` | 插件健康检查 |
| `GET` | `/qqbot/plugin/event/list` | 事件触发插件绑定状态 |
| `POST` | `/qqbot/plugin/event/bind` | 绑定事件触发插件 |
| `POST` | `/qqbot/plugin/event/unbind` | 解绑事件触发插件 |
| `GET` | `/qqbot/dashboard/summary` | QQBot 工作台汇总 |
| `GET` | `/qqbot/send/log/list` | 发送日志分页 |
| `POST` | `/qqbot/send/private` | 发送私聊消息 |
| `POST` | `/qqbot/send/group` | 发送群聊消息 |
| `GET` | `/qqbot/conversation/list` | 会话列表 |
| `GET` | `/qqbot/message/list` | 消息列表 |
| 方法 | 路径 | 说明 |
| ------ | ------------------------------ | ------------------------------------------ |
| `GET` | `/qqbot/plugin/list` | 插件列表,支持 `triggerMode=command/event` |
| `GET` | `/qqbot/plugin/operation/list` | 插件能力列表 |
| `GET` | `/qqbot/plugin/health` | 插件健康检查 |
| `GET` | `/qqbot/plugin/event/list` | 事件触发插件绑定状态 |
| `POST` | `/qqbot/plugin/event/bind` | 绑定事件触发插件 |
| `POST` | `/qqbot/plugin/event/unbind` | 解绑事件触发插件 |
| `GET` | `/qqbot/dashboard/summary` | QQBot 工作台汇总 |
| `GET` | `/qqbot/send/log/list` | 发送日志分页 |
| `POST` | `/qqbot/send/private` | 发送私聊消息 |
| `POST` | `/qqbot/send/group` | 发送群聊消息 |
| `GET` | `/qqbot/conversation/list` | 会话列表 |
| `GET` | `/qqbot/message/list` | 消息列表 |
### OneBot Reverse WebSocket
@ -388,23 +388,23 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
插件 key`bangDream`。当前源码根目录为 `src/qqbot/plugins/bangDream`,不再使用旧 `tsugu` 子目录。
| operation key | 命令 | 说明 |
| --- | --- | --- |
| `bangdream.song.search` | `/查曲` | 查歌曲信息图片 |
| `bangdream.song.chart` | `/查谱面` | 查谱面图片 |
| `bangdream.song.random` | `/随机曲` | 随机歌曲 |
| `bangdream.song.meta` | `/查询分数表` | 查歌曲分数榜 |
| `bangdream.card.search` | `/查卡` | 查卡牌信息图片 |
| `bangdream.card.illustration` | `/查卡面` | 查卡面插画 |
| `bangdream.character.search` | `/查角色` | 查角色信息 |
| `bangdream.event.search` | `/查活动` | 查活动信息 |
| `bangdream.event.stage` | `/查试炼` | 查活动试炼,保持拆图输出 |
| `bangdream.player.search` | `/查玩家` | 查玩家信息 |
| `bangdream.gacha.search` | `/查卡池` | 查卡池 |
| `bangdream.gacha.simulate` | `/抽卡模拟` | 模拟抽卡 |
| `bangdream.cutoff.detail` | `/ycx` | 单档位预测线 |
| `bangdream.cutoff.all` | `/ycxall` | 全档位预测线 |
| `bangdream.cutoff.recent` | `/lsycx` | 历史/近期档线 |
| operation key | 命令 | 说明 |
| ----------------------------- | ------------- | ------------------------ |
| `bangdream.song.search` | `/查曲` | 查歌曲信息图片 |
| `bangdream.song.chart` | `/查谱面` | 查谱面图片 |
| `bangdream.song.random` | `/随机曲` | 随机歌曲 |
| `bangdream.song.meta` | `/查询分数表` | 查歌曲分数榜 |
| `bangdream.card.search` | `/查卡` | 查卡牌信息图片 |
| `bangdream.card.illustration` | `/查卡面` | 查卡面插画 |
| `bangdream.character.search` | `/查角色` | 查角色信息 |
| `bangdream.event.search` | `/查活动` | 查活动信息 |
| `bangdream.event.stage` | `/查试炼` | 查活动试炼,保持拆图输出 |
| `bangdream.player.search` | `/查玩家` | 查玩家信息 |
| `bangdream.gacha.search` | `/查卡池` | 查卡池 |
| `bangdream.gacha.simulate` | `/抽卡模拟` | 模拟抽卡 |
| `bangdream.cutoff.detail` | `/ycx` | 单档位预测线 |
| `bangdream.cutoff.all` | `/ycxall` | 全档位预测线 |
| `bangdream.cutoff.recent` | `/lsycx` | 历史/近期档线 |
`registry/operation-registry.ts` 是 BangDream operation、handlerName、别名、冷却和说明的单一来源。新增或调整命令必须同步在线命令 SQL并跑 registry/command-SQL 测试。
@ -412,9 +412,9 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
插件 key`ff14Market`。
| operation key | 说明 |
| --- | --- |
| `ff14.item.resolve` | 按物品名称或 ID 解析 XIVAPI 物品 |
| operation key | 说明 |
| ------------------- | ------------------------------------------ |
| `ff14.item.resolve` | 按物品名称或 ID 解析 XIVAPI 物品 |
| `ff14.market.price` | 查询指定服务器/大区的 Universalis 市场价格 |
市场查价支持 `item`、`itemId`、`world`、`dataCenter`、`region`、`hq`、`language`。
@ -423,26 +423,26 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
插件 key`fflogs`。
| operation key | 说明 |
| --- | --- |
| operation key | 说明 |
| -------------------------- | --------------------------------------------------------------- |
| `fflogs.character.summary` | 查询 FFLogs 角色公开排名;传 `encounter` 时查询指定高难最近记录 |
常用输入:`characterName`、`serverSlug`、`serverRegion`、`encounter`、`limit`、`metric`、`timeframe`、`zoneId`。
## 初始化 SQL
| 文件 | 用途 |
| --- | --- |
| `sql/vben-admin-init.sql` | 创建 Admin 基础表、用户、角色、菜单、部门、字典和空组件表 |
| `sql/blog-init.sql` | 初始化本地 Blog 表 |
| `sql/blog-menu.sql` | 初始化 Blog 管理菜单 |
| `sql/qqbot-init.sql` | 初始化 QQBot 表、插件命令和字典 |
| `sql/system-log-menu.sql` | 初始化系统日志菜单和权限 |
| `sql/system-notice-menu.sql` | 初始化系统站内信表与菜单权限 |
| `sql/migrate-dict-to-admin-dict.sql` | 旧 `dict` 迁移到 `admin_dict` |
| `sql/migrate-component-to-admin-component.sql` | 旧 `component` 迁移到 `admin_component` |
| `sql/fix-admin-menu-meta.sql` | 修复菜单 meta 被覆盖为空 |
| `sql/fix-admin-user-zero-id.sql` | 修复旧版本 `admin_user.id=0` 脏数据 |
| 文件 | 用途 |
| ---------------------------------------------- | --------------------------------------------------------- |
| `sql/vben-admin-init.sql` | 创建 Admin 基础表、用户、角色、菜单、部门、字典和空组件表 |
| `sql/blog-init.sql` | 初始化本地 Blog 表 |
| `sql/blog-menu.sql` | 初始化 Blog 管理菜单 |
| `sql/qqbot-init.sql` | 初始化 QQBot 表、插件命令和字典 |
| `sql/system-log-menu.sql` | 初始化系统日志菜单和权限 |
| `sql/system-notice-menu.sql` | 初始化系统站内信表与菜单权限 |
| `sql/migrate-dict-to-admin-dict.sql` | 旧 `dict` 迁移到 `admin_dict` |
| `sql/migrate-component-to-admin-component.sql` | 旧 `component` 迁移到 `admin_component` |
| `sql/fix-admin-menu-meta.sql` | 修复菜单 meta 被覆盖为空 |
| `sql/fix-admin-user-zero-id.sql` | 修复旧版本 `admin_user.id=0` 脏数据 |
## 验证入口

View File

@ -16,17 +16,17 @@
## 功能模块
| 模块 | 说明 |
| --- | --- |
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志 |
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
| `qqbot` | QQBot 账号、NapCat 扫码登录、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志 |
| `qqbot/plugins/bangDream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
| `qqbot/plugins/ff14Market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 |
| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除 |
| `common` | 响应封装、异常过滤、请求日志、日期格式化、字典解码、Snowflake、工具服务 |
| 模块 | 说明 |
| -------------------------- | -------------------------------------------------------------------------------- |
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志 |
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
| `qqbot` | QQBot 账号、NapCat 扫码登录、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志 |
| `qqbot/plugins/bangDream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
| `qqbot/plugins/ff14Market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 |
| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除 |
| `common` | 响应封装、异常过滤、请求日志、日期格式化、字典解码、Snowflake、工具服务 |
## 目录结构
@ -53,17 +53,17 @@ ci/ Jenkins Agent/Docker 辅助文件
主要配置分组:
| 分组 | 变量 |
| --- | --- |
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET` |
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD`、`WORDPRESS_*_TIMEOUT_MS` |
| Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` |
| 分组 | 变量 |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET` |
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD`、`WORDPRESS_*_TIMEOUT_MS` |
| Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` |
| QQBot/NapCat | `QQBOT_ENABLED`、`QQBOT_REVERSE_WS_*`、`QQBOT_SEND_*`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*`、`NAPCAT_*`、`QQBOT_NAPCAT_*`、`MQTT_*` |
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_MARKET_CACHE_TTL_MS` |
| FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_MARKET_CACHE_TTL_MS` |
| FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
`DB_SYNC=true` 只适合本地开发或明确允许自动同步表结构的环境;生产应关闭并使用 SQL/迁移脚本。
@ -124,7 +124,7 @@ pnpm exec jest --runInBand --runTestsByPath test/path/to/file.spec.ts
## 核心规则
- 后台主键使用 Snowflake 数字 ID数据库字段为 `BIGINT`,接口按字符串返回。
- 后端响应时间统一用 `YYYY-MM-DD HH:mm:ss`,需要格式化的 DTO/Entity 字段使用 `@FormatDateTime()`
- 后端响应时间统一用 `KtDateTime extends Date` 承接序列化语义Entity 使用 `@KtDateTimeColumn(format)`、`@KtCreateDateColumn(format)`、`@KtUpdateDateColumn(format)` 在 TypeORM hydrate 边界转换DTO/外部数据源使用 `@KtDateTimeField(format)` + `transformKtDateTimeFields()` 转换,默认格式为 `YYYY-MM-DD HH:mm:ss`。`vbenSuccess` / `ToolsService.res` 不做全量递归格式化
- 字典维护在 `admin_dict`Admin 字典管理按 `dictCode` 分组展示;可运营映射优先走字典或静态配置,不硬编码到业务函数。
- 全局 `SaveBodyInterceptor` 会删除 `POST */save` 请求体里的 `id`;需要保留时使用 `@SkipSaveBodyNormalize()`
- Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管理接口默认走 `JwtAuthGuard`;公开接口用 `@Public()`
@ -173,6 +173,6 @@ BangDream 图片能力改动:
## 来源与许可证
| 一级来源 | 使用方式 | License |
| --- | --- | --- |
| [Tsugu BangDream Bot](https://github.com/Yamamoto-2/tsugu-bangdream-bot) | BangDream QQBot 后端能力已重构合入 `src/qqbot/plugins/bangDream`,保留本地 `TSUGU-LICENSE` | MIT |
| 一级来源 | 使用方式 | License |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------- |
| [Tsugu BangDream Bot](https://github.com/Yamamoto-2/tsugu-bangdream-bot) | BangDream QQBot 后端能力已重构合入 `src/qqbot/plugins/bangDream`,保留本地 `TSUGU-LICENSE` | MIT |

View File

@ -1,20 +1,19 @@
import {
AfterLoad,
BeforeInsert,
Column,
Entity,
PrimaryColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
DecodeDictKey,
decodeDictKeys,
ensureSnowflakeId,
FormatDateTime,
KtCreateDateColumn,
KtDateTime,
KtUpdateDateColumn,
} from '@/common';
@Entity('admin_component')
export class Component {
@ApiPropertyOptional()
@ -69,17 +68,15 @@ export class Component {
})
template: string;
@CreateDateColumn({
@KtCreateDateColumn({
name: 'create_time',
})
@FormatDateTime()
createTime: Date;
createTime: KtDateTime;
@UpdateDateColumn({
@KtUpdateDateColumn({
name: 'update_time',
})
@FormatDateTime()
updateTime: Date;
updateTime: KtDateTime;
@Column({
default: 0,

View File

@ -1,13 +1,10 @@
import { BeforeInsert, Column, Entity, PrimaryColumn } from 'typeorm';
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtUpdateDateColumn,
} from '@/common';
@Entity('admin_dept')
export class AdminDept {
@PrimaryColumn({
@ -40,17 +37,15 @@ export class AdminDept {
})
isDeleted: boolean;
@CreateDateColumn({
@KtCreateDateColumn({
name: 'create_time',
})
@FormatDateTime()
createTime: Date;
createTime: KtDateTime;
@UpdateDateColumn({
@KtUpdateDateColumn({
name: 'update_time',
})
@FormatDateTime()
updateTime: Date;
updateTime: KtDateTime;
@BeforeInsert()
createId() {

View File

@ -1,14 +1,11 @@
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { BeforeInsert, Column, Entity, PrimaryColumn } from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
import {
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtUpdateDateColumn,
} from '@/common';
@Entity('admin_dict')
export class AdminDict {
@ApiPropertyOptional()
@ -65,17 +62,15 @@ export class AdminDict {
})
isDeleted: boolean;
@CreateDateColumn({
@KtCreateDateColumn({
name: 'create_time',
})
@FormatDateTime()
createTime: Date;
createTime: KtDateTime;
@UpdateDateColumn({
@KtUpdateDateColumn({
name: 'update_time',
})
@FormatDateTime()
updateTime: Date;
updateTime: KtDateTime;
@BeforeInsert()
createId() {

View File

@ -1,6 +1,6 @@
import { PartialType } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { FormatDateTime } from '@/common';
import { KtDateTime, KtDateTimeField } from '@/common';
export class DictDto {
@ApiProperty({
@ -59,14 +59,14 @@ export class AdminDictDto {
@ApiPropertyOptional({
example: '2026-06-03 20:00:00',
})
@FormatDateTime()
createTime?: Date;
@KtDateTimeField()
createTime?: KtDateTime;
@ApiPropertyOptional({
example: '2026-06-03 20:00:00',
})
@FormatDateTime()
updateTime?: Date;
@KtDateTimeField()
updateTime?: KtDateTime;
}
export class AdminDictTreeDto extends AdminDictDto {

View File

@ -1,13 +1,16 @@
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
ManyToMany,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
import {
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtUpdateDateColumn,
} from '@/common';
import { AdminRole } from '../role/admin-role.entity';
import type { AdminMenuMeta, AdminMenuType } from '../admin.types';
@ -83,17 +86,15 @@ export class AdminMenu {
})
isDeleted: boolean;
@CreateDateColumn({
@KtCreateDateColumn({
name: 'create_time',
})
@FormatDateTime()
createTime: Date;
createTime: KtDateTime;
@UpdateDateColumn({
@KtUpdateDateColumn({
name: 'update_time',
})
@FormatDateTime()
updateTime: Date;
updateTime: KtDateTime;
@ManyToMany(() => AdminRole, (role) => role.menus)
roles: AdminRole[];

View File

@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { FormatDateTime } from '@/common';
import { KtDateTime, KtDateTimeField } from '@/common';
export class AdminNoticeDto {
@ApiProperty({
@ -88,26 +88,26 @@ export class AdminNoticeDto {
@ApiPropertyOptional({
example: '2026-06-03 20:00:00',
})
@FormatDateTime()
createTime?: Date;
@KtDateTimeField()
createTime?: KtDateTime;
@ApiPropertyOptional({
example: '2026-06-03 20:00:00',
})
@FormatDateTime()
updateTime?: Date;
@KtDateTimeField()
updateTime?: KtDateTime;
@ApiPropertyOptional({
example: '2026-06-03 20:00:00',
})
@FormatDateTime()
firstSeenAt?: Date;
@KtDateTimeField()
firstSeenAt?: KtDateTime;
@ApiPropertyOptional({
example: '2026-06-03 20:00:00',
})
@FormatDateTime()
lastSeenAt?: Date;
@KtDateTimeField()
lastSeenAt?: KtDateTime;
}
export class AdminNoticeQueryDto {

View File

@ -1,14 +1,12 @@
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { BeforeInsert, Column, Entity, PrimaryColumn } from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
import {
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtDateTimeColumn,
KtUpdateDateColumn,
} from '@/common';
@Entity('admin_notice')
export class AdminNotice {
@ApiPropertyOptional()
@ -159,33 +157,29 @@ export class AdminNotice {
})
isDeleted: boolean;
@CreateDateColumn({
@KtCreateDateColumn({
name: 'create_time',
})
@FormatDateTime()
createTime: Date;
createTime: KtDateTime;
@UpdateDateColumn({
@KtUpdateDateColumn({
name: 'update_time',
})
@FormatDateTime()
updateTime: Date;
updateTime: KtDateTime;
@Column({
@KtDateTimeColumn({
name: 'first_seen_at',
nullable: true,
type: 'datetime',
})
@FormatDateTime()
firstSeenAt?: Date;
firstSeenAt?: KtDateTime;
@Column({
@KtDateTimeColumn({
name: 'last_seen_at',
nullable: true,
type: 'datetime',
})
@FormatDateTime()
lastSeenAt?: Date;
lastSeenAt?: KtDateTime;
@BeforeInsert()
createId() {

View File

@ -1,14 +1,17 @@
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
JoinTable,
ManyToMany,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
import {
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtUpdateDateColumn,
} from '@/common';
import { AdminMenu } from '../menu/admin-menu.entity';
import { AdminUser } from '../user/admin-user.entity';
@ -44,17 +47,15 @@ export class AdminRole {
})
isDeleted: boolean;
@CreateDateColumn({
@KtCreateDateColumn({
name: 'create_time',
})
@FormatDateTime()
createTime: Date;
createTime: KtDateTime;
@UpdateDateColumn({
@KtUpdateDateColumn({
name: 'update_time',
})
@FormatDateTime()
updateTime: Date;
updateTime: KtDateTime;
@ManyToMany(() => AdminMenu, (menu) => menu.roles)
@JoinTable({

View File

@ -1,8 +1,10 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { FormatDateTime } from '@/common';
import { KtDateTime, KtDateTimeField } from '@/common';
export class SystemLogQueryDto {
@ApiPropertyOptional({ description: '日志级别debug/info/warning/error/critical' })
@ApiPropertyOptional({
description: '日志级别debug/info/warning/error/critical',
})
level?: string;
@ApiPropertyOptional({ description: '全文关键字' })
@ -44,8 +46,8 @@ export class SystemLogDto {
id: string;
@ApiPropertyOptional({ description: '日志时间' })
@FormatDateTime()
timestamp: string;
@KtDateTimeField()
timestamp: KtDateTime;
@ApiPropertyOptional({ description: 'Loki 纳秒时间戳' })
timestampNs: string;

View File

@ -6,7 +6,7 @@ import { URL } from 'node:url';
import {
getAppName,
getLokiEnvironment,
formatDateTimeFields,
transformKtDateTimeFields,
throwVbenError,
ToolsService,
} from '@/common';
@ -97,11 +97,7 @@ export class SystemLogService {
};
}
const { pageNo, pageSize } = this.toolsService.getPageParams(
query,
1,
20,
);
const { pageNo, pageSize } = this.toolsService.getPageParams(query, 1, 20);
const skip = (pageNo - 1) * pageSize;
const requestLimit = Math.min(
this.toolsService.toPositiveNumber(query.limit, skip + pageSize),
@ -119,9 +115,7 @@ export class SystemLogService {
};
}
async summary(
query: SystemLogQueryDto = {},
): Promise<SystemLogSummaryDto[]> {
async summary(query: SystemLogQueryDto = {}): Promise<SystemLogSummaryDto[]> {
if (!this.host) {
return DEFAULT_LEVELS.map((level) => ({ count: 0, level }));
}
@ -239,7 +233,10 @@ export class SystemLogService {
private withLevelSelector(selector: string, level?: string) {
if (!level || selector.includes('level=')) return selector;
return selector.replace(/}\s*$/, `,level="${this.escapeLabelValue(level)}"}`);
return selector.replace(
/}\s*$/,
`,level="${this.escapeLabelValue(level)}"}`,
);
}
private getBaseSelector() {
@ -307,7 +304,7 @@ export class SystemLogService {
const method =
this.pickText(parsed.method, req.method)?.toUpperCase() || undefined;
return formatDateTimeFields(
return transformKtDateTimeFields(
Object.assign(new SystemLogDto(), {
context:
this.pickText(parsed.context, params.stream.context) || undefined,
@ -454,7 +451,9 @@ export class SystemLogService {
}
private asRecord(value: unknown): Record<string, any> {
return value && typeof value === 'object' ? (value as Record<string, any>) : {};
return value && typeof value === 'object'
? (value as Record<string, any>)
: {};
}
private pickText(...values: unknown[]) {
@ -472,7 +471,9 @@ export class SystemLogService {
private toDate(value: unknown) {
const text = this.toolsService.toTrimmedString(value);
if (!text) return null;
const timestamp = /^\d+$/.test(text) ? this.normalizeTimestamp(text) : Date.parse(text);
const timestamp = /^\d+$/.test(text)
? this.normalizeTimestamp(text)
: Date.parse(text);
const date = new Date(timestamp);
return Number.isNaN(date.getTime()) ? null : date;
}

View File

@ -1,16 +1,19 @@
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
import {
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtUpdateDateColumn,
} from '@/common';
import { AdminDept } from '../dept/admin-dept.entity';
import { AdminRole } from '../role/admin-role.entity';
@ -69,17 +72,15 @@ export class AdminUser {
})
isDeleted: boolean;
@CreateDateColumn({
@KtCreateDateColumn({
name: 'create_time',
})
@FormatDateTime()
createTime: Date;
createTime: KtDateTime;
@UpdateDateColumn({
@KtUpdateDateColumn({
name: 'update_time',
})
@FormatDateTime()
updateTime: Date;
updateTime: KtDateTime;
@ManyToMany(() => AdminRole, (role) => role.users, {
eager: true,

View File

@ -1,14 +1,12 @@
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { BeforeInsert, Column, Entity, PrimaryColumn } from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
import {
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtDateTimeColumn,
KtUpdateDateColumn,
} from '@/common';
export type BlogArticleStatus = 'draft' | 'pending' | 'private' | 'publish';
export type BlogArticleTerm = {
@ -117,25 +115,22 @@ export class BlogArticle {
isDeleted: boolean;
@ApiPropertyOptional()
@Column({
@KtDateTimeColumn({
name: 'publish_time',
nullable: true,
type: 'datetime',
})
@FormatDateTime()
publishTime: Date;
publishTime: KtDateTime;
@CreateDateColumn({
@KtCreateDateColumn({
name: 'create_time',
})
@FormatDateTime()
createTime: Date;
createTime: KtDateTime;
@UpdateDateColumn({
@KtUpdateDateColumn({
name: 'update_time',
})
@FormatDateTime()
updateTime: Date;
updateTime: KtDateTime;
@ApiPropertyOptional()
categories?: string[];

View File

@ -1,14 +1,11 @@
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { BeforeInsert, Column, Entity, PrimaryColumn } from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
import {
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtUpdateDateColumn,
} from '@/common';
export type BlogTermKind = 'category' | 'tag';
@Entity('blog_term')
@ -56,17 +53,15 @@ export class BlogTerm {
})
isDeleted: boolean;
@CreateDateColumn({
@KtCreateDateColumn({
name: 'create_time',
})
@FormatDateTime()
createTime: Date;
createTime: KtDateTime;
@UpdateDateColumn({
@KtUpdateDateColumn({
name: 'update_time',
})
@FormatDateTime()
updateTime: Date;
updateTime: KtDateTime;
@ApiPropertyOptional()
count?: number;

View File

@ -1,12 +1,6 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { Column, Entity, PrimaryColumn } from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { FormatDateTime } from '@/common';
import { KtCreateDateColumn, KtDateTime, KtUpdateDateColumn } from '@/common';
import type { WordpressArgonThemeConfig } from '@/wordpress/wordpress.types';
@Entity('blog_theme_config')
@ -30,15 +24,13 @@ export class BlogThemeConfig {
})
source: string;
@CreateDateColumn({
@KtCreateDateColumn({
name: 'create_time',
})
@FormatDateTime()
createTime: Date;
createTime: KtDateTime;
@UpdateDateColumn({
@KtUpdateDateColumn({
name: 'update_time',
})
@FormatDateTime()
updateTime: Date;
updateTime: KtDateTime;
}

View File

@ -1,103 +0,0 @@
import { AfterLoad } from 'typeorm';
export const KT_DATETIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
const DATE_TIME_FORMAT_RULES = Symbol('DATE_TIME_FORMAT_RULES');
const DATE_TIME_FORMAT_HOOK_REGISTERED = Symbol(
'DATE_TIME_FORMAT_HOOK_REGISTERED',
);
const DATE_TIME_FORMAT_HOOK = '__ktFormatDateTimeFields';
const ISO_DATE_TIME_PATTERN =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})?$/;
const DATE_TIME_TEXT_PATTERN =
/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/;
type DateTimeFormatRule = {
sourceKey: string;
targetKey: string;
};
type DateTimeFormatTarget = Record<PropertyKey, any> & {
[DATE_TIME_FORMAT_HOOK_REGISTERED]?: boolean;
[DATE_TIME_FORMAT_RULES]?: DateTimeFormatRule[];
};
const padDateUnit = (value: number) => `${value}`.padStart(2, '0');
export const formatKtDateTime = (value: Date | number | string): string => {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return '';
return [
`${date.getFullYear()}-${padDateUnit(date.getMonth() + 1)}-${padDateUnit(
date.getDate(),
)}`,
`${padDateUnit(date.getHours())}:${padDateUnit(
date.getMinutes(),
)}:${padDateUnit(date.getSeconds())}`,
].join(' ');
};
const formatDateTimeFieldValue = (value: unknown) => {
if (value instanceof Date) {
return formatKtDateTime(value);
}
if (typeof value === 'number' && Number.isFinite(value)) {
return formatKtDateTime(value);
}
if (typeof value === 'string' && isDateTimeText(value)) {
const formatted = formatKtDateTime(value);
return formatted || value;
}
return value;
};
const isDateTimeText = (value: string) =>
ISO_DATE_TIME_PATTERN.test(value) || DATE_TIME_TEXT_PATTERN.test(value);
const getDateTimeFormatRules = (target: object): DateTimeFormatRule[] => {
const prototype = Object.getPrototypeOf(target);
return prototype?.[DATE_TIME_FORMAT_RULES] || [];
};
const ensureDateTimeFormatHook = (target: DateTimeFormatTarget) => {
if (target[DATE_TIME_FORMAT_HOOK_REGISTERED]) return;
Object.defineProperty(target, DATE_TIME_FORMAT_HOOK, {
configurable: true,
value: function (this: object) {
formatDateTimeFields(this);
},
});
AfterLoad()(target, DATE_TIME_FORMAT_HOOK);
target[DATE_TIME_FORMAT_HOOK_REGISTERED] = true;
};
export const FormatDateTime = (): PropertyDecorator => {
return (target, propertyKey) => {
const fieldKey = propertyKey.toString();
const rules = target[DATE_TIME_FORMAT_RULES] || [];
target[DATE_TIME_FORMAT_RULES] = [
...rules.filter(({ targetKey }) => targetKey !== fieldKey),
{
sourceKey: fieldKey,
targetKey: fieldKey,
},
];
ensureDateTimeFormatHook(target as DateTimeFormatTarget);
};
};
export function formatDateTimeFields<T extends object>(target: T): T {
getDateTimeFormatRules(target).forEach(({ sourceKey, targetKey }) => {
target[targetKey] = formatDateTimeFieldValue(target[sourceKey]);
});
return target;
}

View File

@ -0,0 +1,271 @@
import {
Column,
CreateDateColumn,
UpdateDateColumn,
type ColumnOptions,
type ValueTransformer,
} from 'typeorm';
export const KT_DATETIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
const KT_DATETIME_RULES = Symbol('KT_DATETIME_RULES');
const KT_DATETIME_INSTANCE_FORMATS = new WeakMap<Date, string>();
const ISO_DATE_TIME_PATTERN =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})?$/;
const DATE_TIME_TEXT_PATTERN = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/;
type KtDateTimeRule = {
format: string;
sourceKey: string;
targetKey: string;
};
type KtDateTimeRuleTarget = Record<PropertyKey, any> & {
[KT_DATETIME_RULES]?: KtDateTimeRule[];
};
type KtDateTimeDecoratorOptions = string | ColumnOptions;
const padDateUnit = (value: number) => `${value}`.padStart(2, '0');
export class KtDateTime extends Date {
constructor(value?: Date | number | string, format = KT_DATETIME_FORMAT) {
super(value instanceof Date ? value.getTime() : (value ?? Date.now()));
KT_DATETIME_INSTANCE_FORMATS.set(this, format);
}
toJSON(): string {
return this.toString();
}
toString(): string {
return formatKtDateTime(this, getKtDateTimeFormat(this));
}
[Symbol.toPrimitive](hint: 'number'): number;
[Symbol.toPrimitive](hint: 'default' | 'string'): string;
[Symbol.toPrimitive](hint: string): string | number {
return hint === 'number' ? this.getTime() : this.toString();
}
}
export const formatKtDateTime = (
value: Date | number | string,
format = KT_DATETIME_FORMAT,
): string => {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return '';
const tokens: Record<string, string> = {
DD: padDateUnit(date.getDate()),
HH: padDateUnit(date.getHours()),
MM: padDateUnit(date.getMonth() + 1),
YYYY: `${date.getFullYear()}`,
mm: padDateUnit(date.getMinutes()),
ss: padDateUnit(date.getSeconds()),
};
return format.replace(/YYYY|MM|DD|HH|mm|ss/g, (token) => tokens[token]);
};
export const toKtDateTime = (
value: Date | number | string,
format = KT_DATETIME_FORMAT,
) => {
const date = new KtDateTime(value, format);
return Number.isNaN(date.getTime()) ? null : date;
};
export const createKtDateTimeTransformer = (
format = KT_DATETIME_FORMAT,
): ValueTransformer => ({
from: (value: unknown) => transformKtDateTimeValue(value, format),
to: (value: unknown) => {
if (value instanceof Date) return new Date(value.getTime());
if (
(typeof value === 'number' && Number.isFinite(value)) ||
(typeof value === 'string' && isDateTimeText(value))
) {
return toKtDateTime(value, format);
}
return value;
},
});
export function KtDateTimeColumn(): PropertyDecorator;
export function KtDateTimeColumn(format: string): PropertyDecorator;
export function KtDateTimeColumn(options: ColumnOptions): PropertyDecorator;
export function KtDateTimeColumn(
format: string,
options: ColumnOptions,
): PropertyDecorator;
export function KtDateTimeColumn(
formatOrOptions?: KtDateTimeDecoratorOptions,
options?: ColumnOptions,
): PropertyDecorator {
const normalized = normalizeDateTimeColumnOptions(formatOrOptions, options);
return Column({
type: 'datetime',
...normalized.options,
transformer: mergeDateTimeTransformer(
normalized.options.transformer,
normalized.format,
),
});
}
export function KtCreateDateColumn(): PropertyDecorator;
export function KtCreateDateColumn(format: string): PropertyDecorator;
export function KtCreateDateColumn(options: ColumnOptions): PropertyDecorator;
export function KtCreateDateColumn(
format: string,
options: ColumnOptions,
): PropertyDecorator;
export function KtCreateDateColumn(
formatOrOptions?: KtDateTimeDecoratorOptions,
options?: ColumnOptions,
): PropertyDecorator {
const normalized = normalizeDateTimeColumnOptions(formatOrOptions, options);
return CreateDateColumn({
...normalized.options,
transformer: mergeDateTimeTransformer(
normalized.options.transformer,
normalized.format,
),
});
}
export function KtUpdateDateColumn(): PropertyDecorator;
export function KtUpdateDateColumn(format: string): PropertyDecorator;
export function KtUpdateDateColumn(options: ColumnOptions): PropertyDecorator;
export function KtUpdateDateColumn(
format: string,
options: ColumnOptions,
): PropertyDecorator;
export function KtUpdateDateColumn(
formatOrOptions?: KtDateTimeDecoratorOptions,
options?: ColumnOptions,
): PropertyDecorator {
const normalized = normalizeDateTimeColumnOptions(formatOrOptions, options);
return UpdateDateColumn({
...normalized.options,
transformer: mergeDateTimeTransformer(
normalized.options.transformer,
normalized.format,
),
});
}
export const KtDateTimeField = (
format = KT_DATETIME_FORMAT,
): PropertyDecorator => {
return (target, propertyKey) => {
const fieldKey = propertyKey.toString();
const formatTarget = target as KtDateTimeRuleTarget;
const rules = formatTarget[KT_DATETIME_RULES] || [];
formatTarget[KT_DATETIME_RULES] = [
...rules.filter(({ targetKey }) => targetKey !== fieldKey),
{
format,
sourceKey: fieldKey,
targetKey: fieldKey,
},
];
};
};
export function transformKtDateTimeFields<T extends object>(target: T): T {
const result = copyEnumerableFields(target) as Record<string, unknown>;
applyKtDateTimeFields(target, result);
return result as T;
}
function normalizeDateTimeColumnOptions(
formatOrOptions?: KtDateTimeDecoratorOptions,
options: ColumnOptions = {},
) {
if (typeof formatOrOptions === 'string') {
return {
format: formatOrOptions || KT_DATETIME_FORMAT,
options,
};
}
return {
format: KT_DATETIME_FORMAT,
options: formatOrOptions || {},
};
}
function mergeDateTimeTransformer(
existing: ColumnOptions['transformer'],
format: string,
) {
const ktTransformer = createKtDateTimeTransformer(format);
if (!existing) return ktTransformer;
return Array.isArray(existing)
? [...existing, ktTransformer]
: [existing, ktTransformer];
}
function transformKtDateTimeValue(value: unknown, format = KT_DATETIME_FORMAT) {
if (value == null) return value;
if (value instanceof KtDateTime) return value;
if (value instanceof Date) {
return toKtDateTime(value, format) || value;
}
if (
(typeof value === 'number' && Number.isFinite(value)) ||
(typeof value === 'string' && isDateTimeText(value))
) {
return toKtDateTime(value, format) || value;
}
return value;
}
function getKtDateTimeFormat(value: KtDateTime) {
return KT_DATETIME_INSTANCE_FORMATS.get(value) || KT_DATETIME_FORMAT;
}
function isDateTimeText(value: string) {
return (
ISO_DATE_TIME_PATTERN.test(value) || DATE_TIME_TEXT_PATTERN.test(value)
);
}
function getKtDateTimeRules(target: object): KtDateTimeRule[] {
const prototype = Object.getPrototypeOf(target);
return prototype?.[KT_DATETIME_RULES] || [];
}
function applyKtDateTimeFields(
source: object,
target: Record<string, unknown> = source as Record<string, unknown>,
) {
const sourceRecord = source as Record<string, unknown>;
getKtDateTimeRules(source).forEach(({ format, sourceKey, targetKey }) => {
target[targetKey] = transformKtDateTimeValue(
sourceRecord[sourceKey],
format,
);
});
}
function copyEnumerableFields(target: object) {
return Object.entries(target).reduce<Record<string, unknown>>(
(result, [key, value]) => {
result[key] = value;
return result;
},
{},
);
}

View File

@ -1,7 +1,7 @@
export * from './common.module';
export * from './decorators/current-admin-user.decorator';
export * from './decorators/decode-dict.decorator';
export * from './decorators/format-date-time.decorator';
export * from './decorators/kt-date-time.decorator';
export * from './decorators/public.decorator';
export * from './filters/api-exception.filter';
export * from './interceptors/api-request-log.interceptor';

View File

@ -28,7 +28,7 @@ import {
ApiArrayResponse,
ApiModelResponse,
ApiSuccessResponse,
formatDateTimeFields,
transformKtDateTimeFields,
ToolsService,
} from '@/common';
import {
@ -159,7 +159,7 @@ export class MinioClientController {
recursive: recursive !== 'false',
})) as Record<string, unknown>[]
).map((item) =>
formatDateTimeFields(Object.assign(new MinioObjectDto(), item)),
transformKtDateTimeFields(Object.assign(new MinioObjectDto(), item)),
);
res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));

View File

@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { FormatDateTime } from '@/common';
import { KtDateTime, KtDateTimeField } from '@/common';
export class MinioBucketStatusDto {
@ApiProperty({
@ -64,6 +64,6 @@ export class MinioObjectDto {
@ApiProperty({
example: '2026-05-13 10:30:00',
})
@FormatDateTime()
lastModified: string;
@KtDateTimeField()
lastModified: KtDateTime;
}

View File

@ -1,13 +1,10 @@
import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm';
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
Index,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtUpdateDateColumn,
} from '@/common';
import type { QqbotAccountAbilityType } from '../qqbot.types';
@Entity('qqbot_account_ability')
@ -34,13 +31,11 @@ export class QqbotAccountAbility {
@Column({ default: false, name: 'is_deleted' })
isDeleted: boolean;
@CreateDateColumn({ name: 'create_time' })
@FormatDateTime()
createTime: Date;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@UpdateDateColumn({ name: 'update_time' })
@FormatDateTime()
updateTime: Date;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
@BeforeInsert()
createId() {

View File

@ -1,12 +1,11 @@
import { BeforeInsert, Column, Entity, PrimaryColumn } from 'typeorm';
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtDateTimeColumn,
KtUpdateDateColumn,
} from '@/common';
import type {
QqbotConnectionMode,
QqbotConnectionRole,
@ -45,23 +44,21 @@ export class QqbotAccount {
@Column({ default: null, length: 32, name: 'client_role', nullable: true })
clientRole: null | QqbotConnectionRole;
@Column({
@KtDateTimeColumn({
default: null,
name: 'last_connected_at',
nullable: true,
type: 'datetime',
})
@FormatDateTime()
lastConnectedAt: Date | null;
lastConnectedAt: KtDateTime | null;
@Column({
@KtDateTimeColumn({
default: null,
name: 'last_heartbeat_at',
nullable: true,
type: 'datetime',
})
@FormatDateTime()
lastHeartbeatAt: Date | null;
lastHeartbeatAt: KtDateTime | null;
@Column({ default: null, length: 500, name: 'last_error', nullable: true })
lastError: null | string;
@ -72,13 +69,11 @@ export class QqbotAccount {
@Column({ default: false, name: 'is_deleted' })
isDeleted: boolean;
@CreateDateColumn({ name: 'create_time' })
@FormatDateTime()
createTime: Date;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@UpdateDateColumn({ name: 'update_time' })
@FormatDateTime()
updateTime: Date;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
@BeforeInsert()
createId() {

View File

@ -1,12 +1,10 @@
import { BeforeInsert, Column, Entity, PrimaryColumn } from 'typeorm';
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtUpdateDateColumn,
} from '@/common';
import type { QqbotMessageType } from '../qqbot.types';
import type { QqbotCommandLogStatus } from '../qqbot.types';
@ -59,13 +57,11 @@ export class QqbotCommandLog {
})
errorMessage: string | null;
@CreateDateColumn({ name: 'create_time' })
@FormatDateTime()
createTime: Date;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@UpdateDateColumn({ name: 'update_time' })
@FormatDateTime()
updateTime: Date;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
@BeforeInsert()
createId() {

View File

@ -1,13 +1,15 @@
import { BeforeInsert, Column, Entity, PrimaryColumn } from 'typeorm';
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
import type { QqbotCommandParserType, QqbotRuleTargetType } from '../qqbot.types';
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtDateTimeColumn,
KtUpdateDateColumn,
} from '@/common';
import type {
QqbotCommandParserType,
QqbotRuleTargetType,
} from '../qqbot.types';
@Entity('qqbot_command')
export class QqbotCommand {
@ -38,13 +40,28 @@ export class QqbotCommand {
@Column({ default: 'all', length: 32, name: 'target_type' })
targetType: QqbotRuleTargetType;
@Column({ default: null, name: 'default_params', nullable: true, type: 'text' })
@Column({
default: null,
name: 'default_params',
nullable: true,
type: 'text',
})
defaultParams: string | null;
@Column({ default: null, name: 'reply_template', nullable: true, type: 'text' })
@Column({
default: null,
name: 'reply_template',
nullable: true,
type: 'text',
})
replyTemplate: string | null;
@Column({ default: null, name: 'error_template', nullable: true, type: 'text' })
@Column({
default: null,
name: 'error_template',
nullable: true,
type: 'text',
})
errorTemplate: string | null;
@Column({ default: true })
@ -56,14 +73,13 @@ export class QqbotCommand {
@Column({ default: 1500, name: 'cooldown_ms' })
cooldownMs: number;
@Column({
@KtDateTimeColumn({
default: null,
name: 'last_hit_at',
nullable: true,
type: 'datetime',
})
@FormatDateTime()
lastHitAt: Date | null;
lastHitAt: KtDateTime | null;
@Column({ default: '', length: 255 })
remark: string;
@ -71,13 +87,11 @@ export class QqbotCommand {
@Column({ default: false, name: 'is_deleted' })
isDeleted: boolean;
@CreateDateColumn({ name: 'create_time' })
@FormatDateTime()
createTime: Date;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@UpdateDateColumn({ name: 'update_time' })
@FormatDateTime()
updateTime: Date;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
@BeforeInsert()
createId() {

View File

@ -1,14 +1,10 @@
import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm';
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
Index,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtUpdateDateColumn,
} from '@/common';
@Entity('qqbot_config')
@Index('uk_qqbot_config_key', ['configKey'], { unique: true })
export class QqbotConfig {
@ -24,13 +20,11 @@ export class QqbotConfig {
@Column({ default: '', length: 255 })
remark: string;
@CreateDateColumn({ name: 'create_time' })
@FormatDateTime()
createTime: Date;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@UpdateDateColumn({ name: 'update_time' })
@FormatDateTime()
updateTime: Date;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
@BeforeInsert()
createId() {

View File

@ -1,13 +1,11 @@
import { BeforeInsert, Column, Entity, PrimaryColumn } from 'typeorm';
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtDateTimeColumn,
KtUpdateDateColumn,
} from '@/common';
@Entity('qqbot_dedupe')
export class QqbotDedupe {
@PrimaryColumn({ type: 'bigint' })
@ -16,22 +14,19 @@ export class QqbotDedupe {
@Column({ length: 255, name: 'event_key', unique: true })
eventKey: string;
@Column({
@KtDateTimeColumn({
default: null,
name: 'expire_at',
nullable: true,
type: 'datetime',
})
@FormatDateTime()
expireAt: Date | null;
expireAt: KtDateTime | null;
@CreateDateColumn({ name: 'create_time' })
@FormatDateTime()
createTime: Date;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@UpdateDateColumn({ name: 'update_time' })
@FormatDateTime()
updateTime: Date;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
@BeforeInsert()
createId() {

View File

@ -1,13 +1,11 @@
import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm';
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
Index,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtDateTimeColumn,
KtUpdateDateColumn,
} from '@/common';
import type { QqbotMessageType } from '../qqbot.types';
@Entity('qqbot_conversation')
@ -39,14 +37,13 @@ export class QqbotConversation {
@Column({ name: 'last_message_text', type: 'text' })
lastMessageText: string;
@Column({
@KtDateTimeColumn({
default: null,
name: 'last_message_time',
nullable: true,
type: 'datetime',
})
@FormatDateTime()
lastMessageTime: Date | null;
lastMessageTime: KtDateTime | null;
@Column({ default: 0, name: 'message_count' })
messageCount: number;
@ -54,13 +51,11 @@ export class QqbotConversation {
@Column({ default: false, name: 'is_deleted' })
isDeleted: boolean;
@CreateDateColumn({ name: 'create_time' })
@FormatDateTime()
createTime: Date;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@UpdateDateColumn({ name: 'update_time' })
@FormatDateTime()
updateTime: Date;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
@BeforeInsert()
createId() {

View File

@ -1,13 +1,11 @@
import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm';
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
Index,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtDateTimeColumn,
KtUpdateDateColumn,
} from '@/common';
import type { QqbotMessageDirection, QqbotMessageType } from '../qqbot.types';
@Entity('qqbot_message')
@ -57,17 +55,14 @@ export class QqbotMessage {
@Column({ name: 'raw_event', nullable: true, type: 'simple-json' })
rawEvent: Record<string, any>;
@Column({ name: 'event_time', type: 'datetime' })
@FormatDateTime()
eventTime: Date;
@KtDateTimeColumn({ name: 'event_time', type: 'datetime' })
eventTime: KtDateTime;
@CreateDateColumn({ name: 'create_time' })
@FormatDateTime()
createTime: Date;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@UpdateDateColumn({ name: 'update_time' })
@FormatDateTime()
updateTime: Date;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
@BeforeInsert()
createId() {

View File

@ -1,13 +1,11 @@
import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm';
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
Index,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtDateTimeColumn,
KtUpdateDateColumn,
} from '@/common';
import type { QqbotAccountNapcatBindStatus } from '../qqbot.types';
@Entity('qqbot_account_napcat')
@ -29,14 +27,13 @@ export class QqbotAccountNapcat {
@Column({ default: true, name: 'is_primary' })
isPrimary: boolean;
@Column({
@KtDateTimeColumn({
default: null,
name: 'last_login_at',
nullable: true,
type: 'datetime',
})
@FormatDateTime()
lastLoginAt: Date | null;
lastLoginAt: KtDateTime | null;
@Column({ default: '', length: 255 })
remark: string;
@ -44,13 +41,11 @@ export class QqbotAccountNapcat {
@Column({ default: false, name: 'is_deleted' })
isDeleted: boolean;
@CreateDateColumn({ name: 'create_time' })
@FormatDateTime()
createTime: Date;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@UpdateDateColumn({ name: 'update_time' })
@FormatDateTime()
updateTime: Date;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
@BeforeInsert()
createId() {

View File

@ -1,13 +1,11 @@
import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm';
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
Index,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtDateTimeColumn,
KtUpdateDateColumn,
} from '@/common';
import type { QqbotNapcatContainerStatus } from '../qqbot.types';
@Entity('qqbot_napcat_container')
@ -47,23 +45,21 @@ export class QqbotNapcatContainer {
@Column({ default: 'creating', length: 32 })
status: QqbotNapcatContainerStatus;
@Column({
@KtDateTimeColumn({
default: null,
name: 'last_started_at',
nullable: true,
type: 'datetime',
})
@FormatDateTime()
lastStartedAt: Date | null;
lastStartedAt: KtDateTime | null;
@Column({
@KtDateTimeColumn({
default: null,
name: 'last_checked_at',
nullable: true,
type: 'datetime',
})
@FormatDateTime()
lastCheckedAt: Date | null;
lastCheckedAt: KtDateTime | null;
@Column({ default: null, length: 500, name: 'last_error', nullable: true })
lastError: null | string;
@ -74,13 +70,11 @@ export class QqbotNapcatContainer {
@Column({ default: false, name: 'is_deleted' })
isDeleted: boolean;
@CreateDateColumn({ name: 'create_time' })
@FormatDateTime()
createTime: Date;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@UpdateDateColumn({ name: 'update_time' })
@FormatDateTime()
updateTime: Date;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
@BeforeInsert()
createId() {

View File

@ -1,12 +1,10 @@
import { BeforeInsert, Column, Entity, PrimaryColumn } from 'typeorm';
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtUpdateDateColumn,
} from '@/common';
import type { QqbotPermissionTargetType } from '../qqbot.types';
@Entity('qqbot_allowlist')
@ -38,13 +36,11 @@ export class QqbotAllowlist {
@Column({ default: false, name: 'is_deleted' })
isDeleted: boolean;
@CreateDateColumn({ name: 'create_time' })
@FormatDateTime()
createTime: Date;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@UpdateDateColumn({ name: 'update_time' })
@FormatDateTime()
updateTime: Date;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
@BeforeInsert()
createId() {

View File

@ -1,12 +1,10 @@
import { BeforeInsert, Column, Entity, PrimaryColumn } from 'typeorm';
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtUpdateDateColumn,
} from '@/common';
import type { QqbotPermissionTargetType } from '../qqbot.types';
@Entity('qqbot_blocklist')
@ -38,13 +36,11 @@ export class QqbotBlocklist {
@Column({ default: false, name: 'is_deleted' })
isDeleted: boolean;
@CreateDateColumn({ name: 'create_time' })
@FormatDateTime()
createTime: Date;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@UpdateDateColumn({ name: 'update_time' })
@FormatDateTime()
updateTime: Date;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
@BeforeInsert()
createId() {

View File

@ -1,12 +1,11 @@
import { BeforeInsert, Column, Entity, PrimaryColumn } from 'typeorm';
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtDateTimeColumn,
KtUpdateDateColumn,
} from '@/common';
import type { QqbotRuleMatchType, QqbotRuleTargetType } from '../qqbot.types';
@Entity('qqbot_rule')
@ -38,14 +37,13 @@ export class QqbotRule {
@Column({ default: 1500, name: 'cooldown_ms' })
cooldownMs: number;
@Column({
@KtDateTimeColumn({
default: null,
name: 'last_hit_at',
nullable: true,
type: 'datetime',
})
@FormatDateTime()
lastHitAt: Date | null;
lastHitAt: KtDateTime | null;
@Column({ default: '', length: 255 })
remark: string;
@ -53,13 +51,11 @@ export class QqbotRule {
@Column({ default: false, name: 'is_deleted' })
isDeleted: boolean;
@CreateDateColumn({ name: 'create_time' })
@FormatDateTime()
createTime: Date;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@UpdateDateColumn({ name: 'update_time' })
@FormatDateTime()
updateTime: Date;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
@BeforeInsert()
createId() {

View File

@ -1,13 +1,10 @@
import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm';
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
Index,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtUpdateDateColumn,
} from '@/common';
import type { QqbotMessageType, QqbotSendStatus } from '../qqbot.types';
@Entity('qqbot_send_log')
@ -49,13 +46,11 @@ export class QqbotSendLog {
@Column({ nullable: true, type: 'simple-json' })
response: Record<string, any>;
@CreateDateColumn({ name: 'create_time' })
@FormatDateTime()
createTime: Date;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@UpdateDateColumn({ name: 'update_time' })
@FormatDateTime()
updateTime: Date;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
@BeforeInsert()
createId() {

View File

@ -1,50 +0,0 @@
import {
FormatDateTime,
formatDateTimeFields,
formatKtDateTime,
vbenSuccess,
} from '../../src/common';
class DateTimeFormatFixture {
@FormatDateTime()
createTime!: Date;
@FormatDateTime()
updateTime!: string;
}
describe('FormatDateTime', () => {
it('formats decorated fields as YYYY-MM-DD HH:mm:ss', () => {
const data = new DateTimeFormatFixture();
data.createTime = new Date(2026, 4, 13, 10, 30, 0);
data.updateTime = '2026-05-13T10:31:00';
expect(formatDateTimeFields(data)).toEqual({
createTime: '2026-05-13 10:30:00',
updateTime: '2026-05-13 10:31:00',
});
});
it('does not recursively format response payloads', () => {
const createTime = new Date(2026, 4, 13, 10, 30, 0);
const response = vbenSuccess({
createTime,
nested: {
updateTime: '2026-05-13T10:31:00',
},
});
expect(response.data).toEqual({
createTime,
nested: {
updateTime: '2026-05-13T10:31:00',
},
});
});
it('exposes the configured formatter', () => {
expect(formatKtDateTime(new Date(2026, 4, 13, 10, 30, 0))).toBe(
'2026-05-13 10:30:00',
);
});
});

View File

@ -0,0 +1,115 @@
import { getMetadataArgsStorage, type ValueTransformer } from 'typeorm';
import {
transformKtDateTimeFields,
formatKtDateTime,
KtCreateDateColumn,
KtDateTime,
KtDateTimeColumn,
KtDateTimeField,
KtUpdateDateColumn,
vbenSuccess,
} from '../../src/common';
class DateTimeFormatFixture {
@KtDateTimeField()
createTime!: KtDateTime;
@KtDateTimeField('YYYY/HH/DD')
updateTime!: KtDateTime;
}
class DateTimeColumnFixture {
@KtDateTimeColumn('YYYY/HH/DD')
publishTime!: KtDateTime | null;
@KtCreateDateColumn()
createTime!: KtDateTime;
@KtUpdateDateColumn('YYYY-MM-DD')
updateTime!: KtDateTime;
}
describe('KtDateTime decorators', () => {
it('wraps DTO fields with field-level format rules', () => {
const data = new DateTimeFormatFixture();
data.createTime = new Date(2026, 4, 13, 10, 30, 0);
(data as any).updateTime = '2026-05-13T10:31:00';
const formatted = transformKtDateTimeFields(data);
expect(formatted.createTime).toBeInstanceOf(Date);
expect(formatted.createTime).toBeInstanceOf(KtDateTime);
expect(formatted.createTime.getTime()).toBe(data.createTime.getTime());
expect(String(formatted.createTime)).toBe('2026-05-13 10:30:00');
expect(String(formatted.updateTime)).toBe('2026/10/13');
expect(JSON.stringify(formatted)).toBe(
'{"createTime":"2026-05-13 10:30:00","updateTime":"2026/10/13"}',
);
expect(data.createTime).toBeInstanceOf(Date);
expect(data.createTime).not.toBeInstanceOf(KtDateTime);
expect(data.updateTime).toBe('2026-05-13T10:31:00');
});
it('hydrates TypeORM date columns as KtDateTime with column format rules', () => {
const metadata = getMetadataArgsStorage().columns.filter(
(column) => column.target === DateTimeColumnFixture,
);
const getTransformer = (propertyName: string) =>
metadata.find((column) => column.propertyName === propertyName)?.options
.transformer as ValueTransformer;
const publishTime = getTransformer('publishTime').from(
new Date(2026, 4, 13, 10, 30, 0),
);
const createTime = getTransformer('createTime').from(
new Date(2026, 4, 13, 10, 31, 0),
);
const updateTime = getTransformer('updateTime').from(
new Date(2026, 4, 13, 10, 32, 0),
);
expect(publishTime).toBeInstanceOf(KtDateTime);
expect(publishTime?.getTime()).toBe(
new Date(2026, 4, 13, 10, 30, 0).getTime(),
);
expect(String(publishTime)).toBe('2026/10/13');
expect(String(createTime)).toBe('2026-05-13 10:31:00');
expect(String(updateTime)).toBe('2026-05-13');
});
it('keeps DTO transformer datetime fields usable as Date values', () => {
const data = new DateTimeFormatFixture();
data.createTime = new Date(2026, 4, 13, 10, 30, 0);
(data as any).updateTime = '2026-05-13T10:31:00';
const formatted = transformKtDateTimeFields(data);
expect(formatted.createTime).toBeInstanceOf(KtDateTime);
expect(formatted.createTime.getTime()).toBe(
new Date(2026, 4, 13, 10, 30, 0).getTime(),
);
expect(JSON.stringify(formatted)).toBe(
'{"createTime":"2026-05-13 10:30:00","updateTime":"2026/10/13"}',
);
});
it('does not recursively serialize plain response datetime values', () => {
const createTime = new Date(2026, 4, 13, 10, 30, 0);
const updateTime = new Date(2026, 4, 13, 10, 31, 0);
const response = vbenSuccess({
createTime,
nested: {
updateTime,
},
});
expect(response.data.createTime).toBe(createTime);
expect(response.data.nested.updateTime).toBe(updateTime);
});
it('exposes the configured formatter', () => {
expect(formatKtDateTime(new Date(2026, 4, 13, 10, 30, 0))).toBe(
'2026-05-13 10:30:00',
);
});
});

View File

@ -1,10 +1,9 @@
jest.mock('@/common', () => {
const actualCommon = jest.requireActual('@/common');
return {
FormatDateTime: actualCommon.FormatDateTime,
...actualCommon,
ToolsService: actualCommon.ToolsService,
ensureSnowflakeId: jest.fn(),
formatKtDateTime: actualCommon.formatKtDateTime,
setDictDecodeCache: jest.fn(),
throwVbenError: (message: string) => {
throw new Error(message);
@ -147,7 +146,9 @@ describe('QqbotNapcatLoginService', () => {
expect(result.status).toBe('pending');
expect(result.qrcode).toBeUndefined();
expect(result.errorMessage).toBe('NapCat 正在重置登录态并生成二维码,请稍后');
expect(result.errorMessage).toBe(
'NapCat 正在重置登录态并生成二维码,请稍后',
);
expect(containerService.resetRuntimeLoginState).toHaveBeenCalledWith(
container,
expect.any(Function),

View File

@ -2,7 +2,11 @@ jest.mock(
'@/common',
() => ({
ensureSnowflakeId: jest.fn(),
FormatDateTime: () => () => undefined,
KtCreateDateColumn: () => () => undefined,
KtDateTime: Date,
KtDateTimeColumn: () => () => undefined,
KtDateTimeField: () => () => undefined,
KtUpdateDateColumn: () => () => undefined,
formatKtDateTime: (value: Date | number | string) => {
const date = value instanceof Date ? value : new Date(value);
return [
@ -17,6 +21,7 @@ jest.mock(
].join(' ');
},
setDictDecodeCache: jest.fn(),
transformKtDateTimeFields: (value: unknown) => value,
}),
{ virtual: true },
);

View File

@ -1,10 +1,9 @@
jest.mock('@/common', () => {
const actualCommon = jest.requireActual('@/common');
return {
FormatDateTime: actualCommon.FormatDateTime,
...actualCommon,
ToolsService: actualCommon.ToolsService,
ensureSnowflakeId: jest.fn(),
formatKtDateTime: actualCommon.formatKtDateTime,
setDictDecodeCache: jest.fn(),
throwVbenError: (message: string) => {
throw new Error(message);

View File

@ -2,7 +2,11 @@ jest.mock(
'@/common',
() => ({
ensureSnowflakeId: jest.fn(),
FormatDateTime: () => () => undefined,
KtCreateDateColumn: () => () => undefined,
KtDateTime: Date,
KtDateTimeColumn: () => () => undefined,
KtDateTimeField: () => () => undefined,
KtUpdateDateColumn: () => () => undefined,
formatKtDateTime: (value: Date | number | string) => {
const date = value instanceof Date ? value : new Date(value);
return [
@ -17,6 +21,7 @@ jest.mock(
].join(' ');
},
setDictDecodeCache: jest.fn(),
transformKtDateTimeFields: (value: unknown) => value,
}),
{ virtual: true },
);

View File

@ -1,8 +1,14 @@
import { formatKtDateTime, KtDateTime, KtDateTimeColumn } from '@/common';
import {
getEffectiveCooldownMs,
isWithinCooldown,
} from '@/qqbot/qqbot-cooldown.policy';
class CooldownEntityFixture {
@KtDateTimeColumn()
lastHitAt!: KtDateTime;
}
describe('QQBot cooldown policy', () => {
beforeEach(() => {
jest.useFakeTimers();
@ -26,4 +32,20 @@ describe('QQBot cooldown policy', () => {
}),
).toBe(true);
});
it('keeps decorated entity datetime usable for cooldown checks', () => {
const entity = new CooldownEntityFixture();
entity.lastHitAt = new KtDateTime(new Date(Date.now() - 10000));
expect(entity.lastHitAt).toBeInstanceOf(KtDateTime);
expect(`${entity.lastHitAt}`).toBe(formatKtDateTime(entity.lastHitAt));
expect(entity.lastHitAt).toBeInstanceOf(Date);
expect(
isWithinCooldown({
cooldownMs: 500,
lastHitAt: entity.lastHitAt,
minCooldownMs: 30000,
}),
).toBe(true);
});
});