diff --git a/API.md b/API.md index fad24af..7ade0a1 100644 --- a/API.md +++ b/API.md @@ -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` 脏数据 | ## 验证入口 diff --git a/README.md b/README.md index 181114f..d5c0f9f 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/src/admin/component/component.entity.ts b/src/admin/component/component.entity.ts index aee4537..da69ad8 100644 --- a/src/admin/component/component.entity.ts +++ b/src/admin/component/component.entity.ts @@ -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, diff --git a/src/admin/dept/admin-dept.entity.ts b/src/admin/dept/admin-dept.entity.ts index 9af32a3..4d24aa0 100644 --- a/src/admin/dept/admin-dept.entity.ts +++ b/src/admin/dept/admin-dept.entity.ts @@ -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() { diff --git a/src/admin/dict/admin-dict.entity.ts b/src/admin/dict/admin-dict.entity.ts index 21d23c1..2d414c7 100644 --- a/src/admin/dict/admin-dict.entity.ts +++ b/src/admin/dict/admin-dict.entity.ts @@ -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() { diff --git a/src/admin/dict/dict.dto.ts b/src/admin/dict/dict.dto.ts index 9310454..0b3a6f7 100644 --- a/src/admin/dict/dict.dto.ts +++ b/src/admin/dict/dict.dto.ts @@ -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 { diff --git a/src/admin/menu/admin-menu.entity.ts b/src/admin/menu/admin-menu.entity.ts index 942cfbc..48b56af 100644 --- a/src/admin/menu/admin-menu.entity.ts +++ b/src/admin/menu/admin-menu.entity.ts @@ -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[]; diff --git a/src/admin/notice/admin-notice.dto.ts b/src/admin/notice/admin-notice.dto.ts index 9aec755..9272d84 100644 --- a/src/admin/notice/admin-notice.dto.ts +++ b/src/admin/notice/admin-notice.dto.ts @@ -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 { diff --git a/src/admin/notice/admin-notice.entity.ts b/src/admin/notice/admin-notice.entity.ts index d28cc3f..99b5082 100644 --- a/src/admin/notice/admin-notice.entity.ts +++ b/src/admin/notice/admin-notice.entity.ts @@ -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() { diff --git a/src/admin/role/admin-role.entity.ts b/src/admin/role/admin-role.entity.ts index fcebffb..0be3417 100644 --- a/src/admin/role/admin-role.entity.ts +++ b/src/admin/role/admin-role.entity.ts @@ -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({ diff --git a/src/admin/system-log/system-log.dto.ts b/src/admin/system-log/system-log.dto.ts index f436741..8125917 100644 --- a/src/admin/system-log/system-log.dto.ts +++ b/src/admin/system-log/system-log.dto.ts @@ -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; diff --git a/src/admin/system-log/system-log.service.ts b/src/admin/system-log/system-log.service.ts index 3989020..fe4cdc6 100644 --- a/src/admin/system-log/system-log.service.ts +++ b/src/admin/system-log/system-log.service.ts @@ -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 { + async summary(query: SystemLogQueryDto = {}): Promise { 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 { - return value && typeof value === 'object' ? (value as Record) : {}; + return value && typeof value === 'object' + ? (value as Record) + : {}; } 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; } diff --git a/src/admin/user/admin-user.entity.ts b/src/admin/user/admin-user.entity.ts index 31cd702..883f676 100644 --- a/src/admin/user/admin-user.entity.ts +++ b/src/admin/user/admin-user.entity.ts @@ -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, diff --git a/src/blog/blog-article.entity.ts b/src/blog/blog-article.entity.ts index a8f36fa..1afc5f2 100644 --- a/src/blog/blog-article.entity.ts +++ b/src/blog/blog-article.entity.ts @@ -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[]; diff --git a/src/blog/blog-term.entity.ts b/src/blog/blog-term.entity.ts index 81604f7..b407d07 100644 --- a/src/blog/blog-term.entity.ts +++ b/src/blog/blog-term.entity.ts @@ -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; diff --git a/src/blog/blog-theme-config.entity.ts b/src/blog/blog-theme-config.entity.ts index ebe3c1b..6ed830f 100644 --- a/src/blog/blog-theme-config.entity.ts +++ b/src/blog/blog-theme-config.entity.ts @@ -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; } diff --git a/src/common/decorators/format-date-time.decorator.ts b/src/common/decorators/format-date-time.decorator.ts deleted file mode 100644 index ee7bdc5..0000000 --- a/src/common/decorators/format-date-time.decorator.ts +++ /dev/null @@ -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 & { - [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(target: T): T { - getDateTimeFormatRules(target).forEach(({ sourceKey, targetKey }) => { - target[targetKey] = formatDateTimeFieldValue(target[sourceKey]); - }); - - return target; -} diff --git a/src/common/decorators/kt-date-time.decorator.ts b/src/common/decorators/kt-date-time.decorator.ts new file mode 100644 index 0000000..4e8c557 --- /dev/null +++ b/src/common/decorators/kt-date-time.decorator.ts @@ -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(); +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 & { + [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 = { + 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(target: T): T { + const result = copyEnumerableFields(target) as Record; + + 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 = source as Record, +) { + const sourceRecord = source as Record; + + getKtDateTimeRules(source).forEach(({ format, sourceKey, targetKey }) => { + target[targetKey] = transformKtDateTimeValue( + sourceRecord[sourceKey], + format, + ); + }); +} + +function copyEnumerableFields(target: object) { + return Object.entries(target).reduce>( + (result, [key, value]) => { + result[key] = value; + return result; + }, + {}, + ); +} diff --git a/src/common/index.ts b/src/common/index.ts index 5d1d799..cbbe10f 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -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'; diff --git a/src/minio/minio.controller.ts b/src/minio/minio.controller.ts index c1bc662..d014982 100644 --- a/src/minio/minio.controller.ts +++ b/src/minio/minio.controller.ts @@ -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[] ).map((item) => - formatDateTimeFields(Object.assign(new MinioObjectDto(), item)), + transformKtDateTimeFields(Object.assign(new MinioObjectDto(), item)), ); res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result)); diff --git a/src/minio/minio.dto.ts b/src/minio/minio.dto.ts index 1e0281f..d46fa45 100644 --- a/src/minio/minio.dto.ts +++ b/src/minio/minio.dto.ts @@ -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; } diff --git a/src/qqbot/account/qqbot-account-ability.entity.ts b/src/qqbot/account/qqbot-account-ability.entity.ts index afb4f3f..5ec7eab 100644 --- a/src/qqbot/account/qqbot-account-ability.entity.ts +++ b/src/qqbot/account/qqbot-account-ability.entity.ts @@ -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() { diff --git a/src/qqbot/account/qqbot-account.entity.ts b/src/qqbot/account/qqbot-account.entity.ts index 6768a1d..0aa95a0 100644 --- a/src/qqbot/account/qqbot-account.entity.ts +++ b/src/qqbot/account/qqbot-account.entity.ts @@ -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() { diff --git a/src/qqbot/command/qqbot-command-log.entity.ts b/src/qqbot/command/qqbot-command-log.entity.ts index d3cfb01..11fbe97 100644 --- a/src/qqbot/command/qqbot-command-log.entity.ts +++ b/src/qqbot/command/qqbot-command-log.entity.ts @@ -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() { diff --git a/src/qqbot/command/qqbot-command.entity.ts b/src/qqbot/command/qqbot-command.entity.ts index e62f6aa..97ac788 100644 --- a/src/qqbot/command/qqbot-command.entity.ts +++ b/src/qqbot/command/qqbot-command.entity.ts @@ -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() { diff --git a/src/qqbot/config/qqbot-config.entity.ts b/src/qqbot/config/qqbot-config.entity.ts index 4e7557e..d79c8be 100644 --- a/src/qqbot/config/qqbot-config.entity.ts +++ b/src/qqbot/config/qqbot-config.entity.ts @@ -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() { diff --git a/src/qqbot/dedupe/qqbot-dedupe.entity.ts b/src/qqbot/dedupe/qqbot-dedupe.entity.ts index 56acd8c..29f7938 100644 --- a/src/qqbot/dedupe/qqbot-dedupe.entity.ts +++ b/src/qqbot/dedupe/qqbot-dedupe.entity.ts @@ -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() { diff --git a/src/qqbot/message/qqbot-conversation.entity.ts b/src/qqbot/message/qqbot-conversation.entity.ts index f126aed..f3c0800 100644 --- a/src/qqbot/message/qqbot-conversation.entity.ts +++ b/src/qqbot/message/qqbot-conversation.entity.ts @@ -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() { diff --git a/src/qqbot/message/qqbot-message.entity.ts b/src/qqbot/message/qqbot-message.entity.ts index a9d2537..9d03703 100644 --- a/src/qqbot/message/qqbot-message.entity.ts +++ b/src/qqbot/message/qqbot-message.entity.ts @@ -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; - @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() { diff --git a/src/qqbot/napcat/qqbot-account-napcat.entity.ts b/src/qqbot/napcat/qqbot-account-napcat.entity.ts index d46cf8c..5eb4fc8 100644 --- a/src/qqbot/napcat/qqbot-account-napcat.entity.ts +++ b/src/qqbot/napcat/qqbot-account-napcat.entity.ts @@ -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() { diff --git a/src/qqbot/napcat/qqbot-napcat-container.entity.ts b/src/qqbot/napcat/qqbot-napcat-container.entity.ts index 903bd2f..6be40ec 100644 --- a/src/qqbot/napcat/qqbot-napcat-container.entity.ts +++ b/src/qqbot/napcat/qqbot-napcat-container.entity.ts @@ -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() { diff --git a/src/qqbot/permission/qqbot-allowlist.entity.ts b/src/qqbot/permission/qqbot-allowlist.entity.ts index ed2bb07..11af4d9 100644 --- a/src/qqbot/permission/qqbot-allowlist.entity.ts +++ b/src/qqbot/permission/qqbot-allowlist.entity.ts @@ -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() { diff --git a/src/qqbot/permission/qqbot-blocklist.entity.ts b/src/qqbot/permission/qqbot-blocklist.entity.ts index 1620599..245892c 100644 --- a/src/qqbot/permission/qqbot-blocklist.entity.ts +++ b/src/qqbot/permission/qqbot-blocklist.entity.ts @@ -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() { diff --git a/src/qqbot/rule/qqbot-rule.entity.ts b/src/qqbot/rule/qqbot-rule.entity.ts index 04da10d..ec53c99 100644 --- a/src/qqbot/rule/qqbot-rule.entity.ts +++ b/src/qqbot/rule/qqbot-rule.entity.ts @@ -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() { diff --git a/src/qqbot/send/qqbot-send-log.entity.ts b/src/qqbot/send/qqbot-send-log.entity.ts index 9f13198..b9ba060 100644 --- a/src/qqbot/send/qqbot-send-log.entity.ts +++ b/src/qqbot/send/qqbot-send-log.entity.ts @@ -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; - @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() { diff --git a/test/common/format-date-time.decorator.spec.ts b/test/common/format-date-time.decorator.spec.ts deleted file mode 100644 index c557b6f..0000000 --- a/test/common/format-date-time.decorator.spec.ts +++ /dev/null @@ -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', - ); - }); -}); diff --git a/test/common/kt-date-time.decorator.spec.ts b/test/common/kt-date-time.decorator.spec.ts new file mode 100644 index 0000000..a497d42 --- /dev/null +++ b/test/common/kt-date-time.decorator.spec.ts @@ -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', + ); + }); +}); diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index 7c75a24..c08a710 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -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), diff --git a/test/qqbot/command/qqbot-command-parser.service.spec.ts b/test/qqbot/command/qqbot-command-parser.service.spec.ts index 573f60a..6bb0922 100644 --- a/test/qqbot/command/qqbot-command-parser.service.spec.ts +++ b/test/qqbot/command/qqbot-command-parser.service.spec.ts @@ -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 }, ); diff --git a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts index dd36bd8..a5a405c 100644 --- a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts +++ b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts @@ -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); diff --git a/test/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts b/test/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts index c1acdf3..ca6e984 100644 --- a/test/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts +++ b/test/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts @@ -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 }, ); diff --git a/test/qqbot/qqbot-cooldown.policy.spec.ts b/test/qqbot/qqbot-cooldown.policy.spec.ts index 40745bf..318893c 100644 --- a/test/qqbot/qqbot-cooldown.policy.spec.ts +++ b/test/qqbot/qqbot-cooldown.policy.spec.ts @@ -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); + }); });