From f4454ad0149a000b0044e153788cd324e9ab56c3 Mon Sep 17 00:00:00 2001 From: andyesfly <35127884@qq.com> Date: Fri, 2 Oct 2026 16:08:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=94=B5=E5=B7=A5APP?= =?UTF-8?q?=E7=AB=AF=E4=B8=8E=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增电工APP独立鉴权中间件与登录注册接口 - 新增定时任务管理模块,支持任务配置、执行日志与手动触发 - 新增运维费定价与电工派单成本价目管理 - 新增电工资质证照管理与到期提醒定时任务 - 完善数据库实体与DTO校验,优化权限控制 --- .../checklist.md | 87 + .../spec.md | 293 ++++ .../tasks.md | 92 + .../add-scheduled-job-management/checklist.md | 64 + .../add-scheduled-job-management/spec.md | 322 ++++ .../add-scheduled-job-management/tasks.md | 76 + client/src/api/dispatchPrice.ts | 61 + client/src/api/job.ts | 113 ++ client/src/api/notice.ts | 42 + client/src/api/servicePrice.ts | 61 + client/src/api/workerRegistry.ts | 128 ++ client/src/layouts/index.vue | 238 ++- client/src/router/index.ts | 26 +- client/src/views/dashboard/index.vue | 114 ++ client/src/views/dispatch/cost/index.vue | 864 +++++++++ client/src/views/dispatch/price/index.vue | 871 ++++++++++ client/src/views/dispatch/worker/index.vue | 1539 +++++++++++++++++ client/src/views/system/job/index.vue | 649 +++++++ database/db_seed.sql | 96 +- .../20261001_om_worker_registry.sql | 221 +++ .../migrations/20261002_job_management.sql | 123 ++ server/package.json | 2 + server/src/configuration.ts | 21 +- .../controller/dispatch_price.controller.ts | 42 + server/src/controller/job.controller.ts | 41 + server/src/controller/notice.controller.ts | 37 + .../controller/service_price.controller.ts | 42 + server/src/controller/worker.controller.ts | 33 +- .../src/controller/worker_app.controller.ts | 79 + server/src/dto/dispatch_price.dto.ts | 52 + server/src/dto/job.dto.ts | 63 + server/src/dto/service_price.dto.ts | 52 + server/src/dto/worker.dto.ts | 70 +- server/src/dto/worker_app.dto.ts | 95 + server/src/entity/dispatch_price.entity.ts | 22 +- server/src/entity/service_price.entity.ts | 47 + server/src/entity/sys_job.entity.ts | 105 ++ server/src/entity/sys_job_log.entity.ts | 94 + server/src/entity/sys_notice.entity.ts | 41 + server/src/entity/worker.entity.ts | 23 +- server/src/entity/worker_cert.entity.ts | 47 + server/src/middleware/auth.middleware.ts | 4 +- server/src/middleware/rbac.middleware.ts | 17 +- .../src/middleware/worker_auth.middleware.ts | 68 + server/src/service/dispatch_price.service.ts | 135 ++ server/src/service/job.service.ts | 231 +++ server/src/service/job_scheduler.service.ts | 429 +++++ server/src/service/notice.service.ts | 251 +++ server/src/service/service_price.service.ts | 138 ++ server/src/service/worker.service.ts | 405 ++++- server/src/service/worker_app.service.ts | 326 ++++ server/src/task/cert_expire.task.ts | 19 + server/src/task/demo_heartbeat.task.ts | 24 + server/src/task/job_registry.ts | 59 + server/src/task/log_clean.task.ts | 22 + server/src/util/token.ts | 34 + server/yarn.lock | 338 ++-- 57 files changed, 9388 insertions(+), 200 deletions(-) create mode 100644 .trae/specs/add-om-pricing-and-worker-registry/checklist.md create mode 100644 .trae/specs/add-om-pricing-and-worker-registry/spec.md create mode 100644 .trae/specs/add-om-pricing-and-worker-registry/tasks.md create mode 100644 .trae/specs/add-scheduled-job-management/checklist.md create mode 100644 .trae/specs/add-scheduled-job-management/spec.md create mode 100644 .trae/specs/add-scheduled-job-management/tasks.md create mode 100644 client/src/api/dispatchPrice.ts create mode 100644 client/src/api/job.ts create mode 100644 client/src/api/notice.ts create mode 100644 client/src/api/servicePrice.ts create mode 100644 client/src/api/workerRegistry.ts create mode 100644 client/src/views/dispatch/cost/index.vue create mode 100644 client/src/views/dispatch/price/index.vue create mode 100644 client/src/views/dispatch/worker/index.vue create mode 100644 client/src/views/system/job/index.vue create mode 100644 database/migrations/20261001_om_worker_registry.sql create mode 100644 database/migrations/20261002_job_management.sql create mode 100644 server/src/controller/dispatch_price.controller.ts create mode 100644 server/src/controller/job.controller.ts create mode 100644 server/src/controller/notice.controller.ts create mode 100644 server/src/controller/service_price.controller.ts create mode 100644 server/src/controller/worker_app.controller.ts create mode 100644 server/src/dto/dispatch_price.dto.ts create mode 100644 server/src/dto/job.dto.ts create mode 100644 server/src/dto/service_price.dto.ts create mode 100644 server/src/dto/worker_app.dto.ts create mode 100644 server/src/entity/service_price.entity.ts create mode 100644 server/src/entity/sys_job.entity.ts create mode 100644 server/src/entity/sys_job_log.entity.ts create mode 100644 server/src/entity/sys_notice.entity.ts create mode 100644 server/src/entity/worker_cert.entity.ts create mode 100644 server/src/middleware/worker_auth.middleware.ts create mode 100644 server/src/service/dispatch_price.service.ts create mode 100644 server/src/service/job.service.ts create mode 100644 server/src/service/job_scheduler.service.ts create mode 100644 server/src/service/notice.service.ts create mode 100644 server/src/service/service_price.service.ts create mode 100644 server/src/service/worker_app.service.ts create mode 100644 server/src/task/cert_expire.task.ts create mode 100644 server/src/task/demo_heartbeat.task.ts create mode 100644 server/src/task/job_registry.ts create mode 100644 server/src/task/log_clean.task.ts diff --git a/.trae/specs/add-om-pricing-and-worker-registry/checklist.md b/.trae/specs/add-om-pricing-and-worker-registry/checklist.md new file mode 100644 index 0000000..a8a77d5 --- /dev/null +++ b/.trae/specs/add-om-pricing-and-worker-registry/checklist.md @@ -0,0 +1,87 @@ +# Checklist + +> change-id: `add-om-pricing-and-worker-registry` +> 逐条核对,通过后勾选。 +> 验证方式说明: +> - 「API 实测」= 在本地开发库(127.0.0.1:5432/xintong)起 `MIDWAY_SERVER_ENV=local` 实例,用 admin 真实 JWT 逐条打接口 + 直连数据库比对; +> - 「浏览器实测」= `vite` + 本地后端,用浏览器实机登录后走查; +> - 「代码核对」= 无法在运行时构造的场景,以代码 + 结构(约束/索引)核对。 + +## 数据库与实体 + +- [x] `database/migrations/20261001_om_worker_registry.sql` 已创建,覆盖 `worker` / `worker_cert` / `service_price` / `dispatch_price` / `sys_notice` 五处变更,且可重复执行无报错 —— API 实测:本地库重跑迁移无报错,`worker` 18 列、`dispatch_price` 11 列不变,`sys_menu` 34-46 仍为 13 行无重复 +- [x] `database/db_seed.sql` 已同步菜单(id 34-46)、字典(5 个 dict_type)、ADMIN 角色绑定,新库初始化与迁移后结构一致 —— 代码核对(Task 1 已完成并经迁移对照) +- [x] `worker` 实体含 `password_hash` / `audit_status` / `audit_remark` / `audit_by` / `audit_at` / `last_login_at` —— 代码核对 + 库表列核对(6 列均在) +- [x] `dispatch_price` 实体含 `price_mode` / `unit` / `status` / `remark` / `updated_at` —— 代码核对 + 库表列核对(5 列均在) +- [x] `worker_cert` / `service_price` / `sys_notice` 实体已创建,索引与迁移脚本一致 —— API 实测 `uk_worker_cert_type_no` / `uk_service_price_code` / `uk_sys_notice_biz` 均生效 + +## 客户运维价目(FR-1) + +- [x] `/api/service-prices` 支持分页、关键词(编码 / 名称)、`price_mode` / `service_type` / `status` 筛选 —— API 实测四种筛选均只返回命中记录 +- [x] `price_code` 自动生成为 `FW+YYYYMMDD+3 位序号` 且不可编辑 —— API 实测连建三条得到 `FW20261001001`~`003`,`priceCode` 在白名单外故不可写 +- [x] `price_mode` 为 1 按次 / 2 按工时 / 3 材料 三种取值均可新增、编辑、筛选 —— API 实测 +- [x] 支持启用 / 停用与硬删除两个独立操作 —— API 实测 `PUT /:id/status` 与 `DELETE /:id`(删除后查库确认为物理删除,重复删返回 404) + +## 电工派单成本价(FR-2) + +- [x] 存量 `dispatch_price` 行迁移后 `price_mode=1`、`status=1` 无 NULL —— API 实测存量 2 行均为 `priceMode=1 / status=1` +- [x] `/api/dispatch-prices` CRUD + 筛选可用,`price_code` 冲突返回 400 且 message 明确 —— CRUD/筛选 API 实测通过;`UniqueConstraintError`→「价格编码已存在」分支为代码核对(生成器保证编码不重复,无法从接口构造冲突) + +## 电工登记与审核(FR-3 / FR-4) + +- [x] `/api/workers` 支持分页、关键词、`status` / `auditStatus` / `certStatus` 筛选 —— API 实测 + 浏览器实测(已过期 1 条 / 无证书 3 条,筛选结果与库一致) +- [x] 创建时手机号唯一校验生效;默认密码规则为手机号后 6 位(不足 6 位用 `123456`)并 bcrypt 存储 —— API 实测:重复手机号返回 `手机号已存在`;`password_hash` 以 `$2a$10$` 开头,用手机号后 6 位可成功登录电工 APP +- [x] 审核通过写入 `audit_status=1` / `audit_by` / `audit_at`,清空 `audit_remark`,并置 `cert_verified=true` —— API 实测(库中字段逐项比对) +- [x] 审核驳回未填理由返回 400;填写后 `audit_status=2` 且理由、审核人、时间落库 —— API 实测:不填理由 → `code:400「请填写驳回理由」`;填理由 → `audit_status=2 / audit_remark / audit_by / audit_at` 落库 +- [x] 删除电工级联删除其 `worker_cert`;存在派单引用时返回 400 并提示 —— API 实测:删除后 `worker_cert` 1→0;自造 `survey_order` 引用后返回 `code:400「该电工存在派单记录,无法删除」` +- [x] `/api/workers/options` 与 `/recommend` 仅返回 `audit_status=1 AND status=1` 的电工 —— 代码核对(查询条件已收紧)+ API 实测两接口正常返回 + +## 资质证书与到期状态(FR-5 / FR-6) + +- [x] `worker_cert` 支持一电工多证,`(worker_id, cert_type, cert_no)` 唯一约束生效 —— API 实测一电工挂多证;重复插入触发 `uk_worker_cert_type_no` 唯一冲突 +- [x] 资质状态计算正确:无证书 / 正常 / 临期(≤30 天)/ 已过期(`expire_date < today`),`expire_date` 为 NULL 视为长期有效 —— API 实测:构造 +10 天 / -5 天 / +200 天 / NULL 四种证书,`certStatus` 四种筛选各自只返回对应记录 +- [x] 电工列表「资质状态」列标签颜色正确(绿 / 橙 / 红 / 灰)—— 浏览器实测:列表渲染出「已过期」彩色标签;配色取自 `a-tag` 状态映射(代码核对) +- [x] 列表 KPI 卡统计「电工总数 / 待审核 / 临期 / 已过期」数值正确 —— API 实测 `stats` 与独立 SQL 计算一致;浏览器实测 KPI 显示 5 / 4 / 0 / 1,与接口一致 +- [x] 仪表盘「电工资质到期提醒」区块展示临期与已过期证书清单并可跳转 —— 浏览器实测:区块展示「验收测试电工 | 高压电工证 | 2026-09-26 | 已过期 5 天」,且可跳转电工登记管理页 + +## 站内通知与定时任务(FR-7) + +- [x] `sys_notice` UNIQUE(`notice_type`,`biz_id`,`receiver_id`) 生效,重复执行定时任务不产生重复通知 —— API 实测:直接重复 INSERT 报 `uk_sys_notice_biz` 唯一冲突;真实调用 `scanCertExpireAndNotify()` 三次,`created` 依次为 1 / 0 / 0 +- [x] 定时任务每日 08:00 扫描 `expire_date <= today + 30 天` 的证书,为每个 ADMIN 用户生成未读通知 —— 代码核对(`@Job({ cronTime: "0 0 8 * * *", timeZone: "Asia/Shanghai" })`)+ 实测扫描逻辑生成 ADMIN 未读通知 +- [x] 证书续期或被删除时,该证书既有未读通知被清理 —— API 实测:续期(改 `expireDate`)后该证书通知 1→0;删除证书后亦为 0 +- [x] `/api/notices` 系列接口仅返回 `receiver_id = 当前用户` 的数据,未读数接口正确 —— API 实测:列表仅返回本人;读他人通知返回 404;`unread-count` 与库中计数一致 +- [x] 顶栏铃铛红点角标随未读数变化;单条已读 / 全部已读生效;点击通知跳转电工登记页 —— 浏览器实测:角标显示 2,抽屉内 2 条「验收测试」通知 + +## 电工 APP 接口(FR-8) + +- [x] `/api/worker-app/auth/register` 与 `/login` 可匿名访问,注册手机号唯一,登录返回 worker token(payload 含 `workerId` / `phone` / `userType=worker`)—— API 实测注册/登录成功;重复手机号被拒 +- [x] `WorkerAuthMiddleware` 仅作用于 `/api/worker-app/*`,且放行 `/api/worker-app/auth/*` —— API 实测:匿名可访问 `auth/*`,未带 token 访问 `/profile` 返回 401 +- [x] 电工 APP 无法通过 `:id` 越权读取 / 修改他人资料与证书(返回 403)—— API 实测:电工 X 的 token 改/删电工 Y 的证书 → `code:403「无权操作该证书」`,Y 数据未被改动 +- [x] `POST /api/worker-app/audit/resubmit` 仅在 `audit_status=2` 时成功,`audit_status=1` 时返回 400 —— API 实测 +- [x] `auth.middleware.ts` 与 `rbac.middleware.ts` 均已忽略 `/api/worker-app/*` —— 代码核对 + API 实测(电工 token 不会被管理端鉴权拦截) + +## 菜单、权限与路由(FR-9 / FR-10) + +- [x] `运营派单`(id=3)已改为目录,管理端侧边栏展开可见 3 个子菜单 —— API 实测登录菜单树中 id=3 为 `menuType=1` 且含 34/35/36;浏览器实测展开可见三个子菜单 +- [x] ADMIN 可见「运维费定价」「电工派单成本价」「电工登记管理」;SALESMAN 不可见「运营派单」及其子菜单 —— API 实测:自签 SALESMAN token 调 `/auth/currentUser`,`dispatch:list` / `dispatch:price` / `dispatch:cost` / `dispatch:worker` 均为 false +- [x] 前端 `/dispatch` 重定向到 `/dispatch/price`,3 条子路由 `meta.permission` 正确 —— 代码核对(router 已配置 `redirect` 与 `meta.permission`)+ 浏览器实测(首次因后端瞬时抖动显示报错,复测页面正常渲染) +- [x] `/api/service-prices`、`/api/dispatch-prices`、`/api/workers` 已纳入 RBAC 权限映射 —— API 实测:SALESMAN 调 `/api/workers`、`/api/service-prices` 均 HTTP 403 +- [x] SALESMAN 调用 `/api/workers/options`、`/api/workers/recommend` 正常返回(白名单生效),调用 `/api/workers` 列表返回 403 —— API 实测 HTTP 200 / 403 + +## 安全与规范(NFR) + +- [x] `GET /api/workers` 与详情接口不返回 `password_hash` —— API 实测:列表 / 详情 / `certStatus` 各分支返回体均无 `password_hash` / `passwordHash` +- [x] 所有查询走 Sequelize 参数化 API,无字符串拼接 SQL 与模板字符串 LIKE —— 代码核对:模糊查询统一 `Op.like` + 绑定值,无原始 SQL 拼接 +- [x] 前端页面未使用 `v-html`,表单输入含长度与特殊字符校验 —— 代码核对:三个新页面无 `v-html`(grep 0 命中),输入框均带 `maxlength`,手机号等有正则校验 +- [x] 列表接口均走 `limit + offset` 分页,证书聚合无 N+1 查询 —— 代码核对:`worker_cert` 用 `worker_id IN (...)` 批量加载后按电工分组 +- [x] Controller 参数使用 DTO + `class-validator`,无滥用 `any`(除既有约定处)—— `worker` / `worker-app` 用 DTO 类型;`service-price` / `dispatch-price` / `notice` 沿用工程既有约定(`@Body() body: any`,业务校验在 service 层)。注:工程未注册 `@midwayjs/validate` 组件,DTO 装饰器全站不生效,属既有约定 + +## 端到端 + +- [x] 后端 `yarn dev` 启动无编译错误 —— API 实测:`MIDWAY_SERVER_ENV=local` 实例启动成功;`npx tsc --noEmit` 退出码 0 +- [x] 前端 `yarn dev` 启动,三个新页面 CRUD 全链路可用 —— 前端 `vite` 启动成功、`vue-tsc --noEmit` 退出码 0;浏览器实测三个页面均正常渲染、弹窗字段齐备、KPI/筛选/铃铛/仪表盘联动正常;CRUD 接口链路已在 API 层全量实测(浏览器内「填表→点提交」这一步受自动化工具步骤预算限制未跑通) +- [x] AC-1 ~ AC-11 逐条走通并留痕 —— 见上方各项实测证据;验证数据已清理,数据库恢复至验证前基线(`worker=4 / worker_cert=0 / sys_notice=0 / service_price=0 / dispatch_price=2`) + +## 验证阶段修复 + +- [x] 修复 `WorkerAuthMiddleware` 同时声明 `match` 与 `ignore`:Midway 抛 `MIDWAY_10001: options.match and options.ignore can not both present`,导致**全站所有 `/api/*` 返回 500**;已改为仅 `match`,`/api/worker-app/auth/*` 在 `resolve()` 内放行 —— API 实测修复后匿名访问新接口由 500 恢复为 401 \ No newline at end of file diff --git a/.trae/specs/add-om-pricing-and-worker-registry/spec.md b/.trae/specs/add-om-pricing-and-worker-registry/spec.md new file mode 100644 index 0000000..a83aa86 --- /dev/null +++ b/.trae/specs/add-om-pricing-and-worker-registry/spec.md @@ -0,0 +1,293 @@ +# 运维费定价与电工登记管理 Spec + +> change-id: `add-om-pricing-and-worker-registry` +> 范围:`smart_grid_iot`(后端 server + 管理端 client);电工 APP 仅交付后端接口,不改 `smart_grid_iot_app` 页面。 + +## Why + +系统当前缺少两块运营基础能力: + +1. **运维费定价缺失**:既没有面向客户的运维收费价目表,也没有电工派单成本价目的维护界面。数据库设计 D10 已有 `dispatch_price`(共享电工派单价格)实体,但只有 entity、无接口、无页面,无法维护。 +2. **电工登记与资质管理缺失**:`worker` 表只有只读的列表 / 下拉 / 推荐接口(供项目勘察派单选用),无法登记新电工、无资质审核流转、无资质到期提醒;`worker` 实体甚至缺少设计文档中已有的 `password_hash`、`last_login_at` 字段,电工 APP 无法登录。 + +这两块是后续派单、维保、结算业务的前置主数据,需先补齐。 + +## What Changes + +- 新增客户运维服务价目表 `service_price`:全局统一价目,用 `price_mode` 单表承载 **按次 / 按工时 / 材料** 三种计价方式,提供 CRUD + 启用停用。 +- 扩展现有 `dispatch_price`(电工派单成本价目):新增 `price_mode`(按次 / 按工时)、`unit`、`status`、`remark`、`updated_at`,补齐 CRUD 接口与管理页面。 +- 扩展 `worker` 表:补 `password_hash`、`audit_status`、`audit_remark`、`audit_by`、`audit_at`、`last_login_at`;新增登记 CRUD 与资质审核流转(待审核 → 已通过 / 已驳回,可重新提交)。 +- 新增电工资质证照子表 `worker_cert`(一电工多证:证书类型 / 编号 / 发证机关 / 发证日期 / 有效期至 / 证照图片),按期日期逐证计算「正常 / 临期 / 已过期」。 +- 新增 `sys_notice` 站内通知表 + `@midwayjs/cron` 定时任务(每日扫描证书有效期,**提前 30 天**生成提醒,接收人仅 ADMIN 用户);管理端顶栏新增消息铃铛 + 未读红点 + 消息中心抽屉。 +- 新增电工 APP 后端接口 `/api/worker-app/*`:自助注册、登录、查看 / 编辑本人资料、上传资质、查看审核结果与到期提醒;配套独立电工 JWT 与鉴权中间件。**不修改 `smart_grid_iot_app` 工程页面**。 +- 菜单调整:`运营派单`(id=3)由菜单项改为目录(`menu_type=1`),下挂 3 个子菜单「运维费定价」「电工派单成本价」「电工登记管理」及对应按钮权限。 +- **BREAKING**(仅限内部路由/权限): + - 管理端 `/dispatch` 路由由 Placeholder 占位页改为目录(重定向到 `/dispatch/price`)。 + - `/api/workers` 由「登录即可访问」改为受 `dispatch:worker` 权限控制;为保证项目勘察派单页正常,`/api/workers/options`、`/api/workers/recommend` 加入鉴权白名单。 + - `dispatch_price` 表新增 NOT NULL 默认值字段(`price_mode` 默认 1、`status` 默认 1),存量数据自动补默认值。 + +## Impact + +- **Affected specs**:客户管理核心(复用 `upload` / `file_record`)、项目管理-现场勘察派单(复用 `workers/options`、`workers/recommend`)、系统管理与 RBAC(`rbac.middleware.ts` + `db_seed.sql` 菜单/字典) +- **Affected code**: + - 后端实体:新增 `service_price.entity.ts`、`worker_cert.entity.ts`、`sys_notice.entity.ts`;修改 `worker.entity.ts`、`dispatch_price.entity.ts` + - 后端接口:新增 `service_price.*`、`dispatch_price.*`、`notice.*`、`worker_app.*`;扩展 `worker.*` + - 后端横切:`middleware/workerAuth.middleware.ts`(新增)、`middleware/rbac.middleware.ts`(改)、`util/token.ts`(改)、`task/cert_expire.task.ts`(新增)、`package.json`(新增 `@midwayjs/cron`) + - 数据库:新增 `database/migrations/20261001_om_worker_registry.sql`;同步修订 `database/db_seed.sql` + - 前端:新增 `api/servicePrice.ts`、`api/dispatchPrice.ts`、`api/workerRegistry.ts`、`api/notice.ts`;新增 `views/dispatch/price/index.vue`、`views/dispatch/cost/index.vue`、`views/dispatch/worker/index.vue`;修改 `router/index.ts`、`layouts/index.vue` + +--- + +## ADDED Requirements + +### FR-1:客户运维服务价目管理(`service_price`) + +系统 SHALL 提供面向客户的运维收费价目 CRUD 与启停用能力。 + +- 表字段:`id`、`price_code`(UNIQUE,自动生成,规则 `FW + YYYYMMDD + 3 位序号`)、`price_name`(必填)、`service_type`(服务类型:维修 / 维保 / 报警处理 / 巡检 / 其他,取自字典 `om_service_type`)、`price_mode`(计价方式:1 按次 / 2 按工时 / 3 材料,取自字典 `om_price_mode`)、`unit_price`(NUMERIC(10,2),必填,≥ 0)、`unit`(单位:次 / 小时 / 个 / 米 / 根,取自字典 `om_price_unit`)、`status`(1 启用 / 0 停用,默认 1)、`remark`、`created_at`、`updated_at` +- 列表:分页(`page` / `pageSize`,`pageSize ≤ 200`);关键词模糊搜索(价目编码 / 名称);筛选 `price_mode`、`service_type`、`status` +- 创建 / 更新:`price_code` 只读不可改;字段白名单更新;`price_mode` 与 `unit` 必须成对合理(按次默认「次」、按工时默认「小时」、材料默认「个」) +- 删除:硬删除;停用(`status=0`)与删除为两个独立操作 +- 索引:`price_code` UNIQUE、`(price_mode, status)` 联合索引 +- `price_mode` 三种取值本期全部开放录入(零成本满足「后续扩展按工时 / 材料」诉求),不做前端隐藏 + +### FR-2:电工派单成本价目管理(`dispatch_price` 扩展) + +系统 SHALL 在现有 `dispatch_price` 基础上补齐管理能力。 + +- 新增字段:`price_mode`(1 按次 / 2 按工时,默认 1,NOT NULL)、`unit`(次 / 小时,默认「次」)、`status`(1 启用 / 0 停用,默认 1,NOT NULL)、`remark`、`updated_at` +- 保留原有字段与语义:`price_code`(UNIQUE)、`dispatch_type`(勘察 / 安装 / 预警 / 维修 / 维保)、`price_type`(单价 / 总价提成)、`unit_price` +- 列表:分页 + 关键词(编码)+ 筛选 `dispatch_type`、`price_mode`、`status` +- 创建 / 更新 / 删除:`price_code` 唯一冲突返回 400 且 message 明确;字段白名单更新 +- 本期不含「材料」成本项(材料归属客户侧收费价目 FR-1) + +### FR-3:电工登记管理(`worker` 扩展) + +系统 SHALL 提供电工登记 CRUD。 + +- 新增字段: + - `password_hash`(VARCHAR(255),电工 APP 登录密码 bcrypt 哈希,允许 NULL) + - `audit_status`(SMALLINT,0 待审核 / 1 已通过 / 2 已驳回,默认 0,NOT NULL) + - `audit_remark`(VARCHAR(255),驳回理由 / 审核备注) + - `audit_by`(BIGINT,审核人 `sys_user.id`)、`audit_at`(TIMESTAMPTZ) + - `last_login_at`(TIMESTAMPTZ) +- 保留字段:`worker_name`、`phone`(UNIQUE,登录账号)、`id_card`、`worker_title`、`rating`、`cert_info`、`cert_verified`、`completed_count`、`status`(1 在岗 / 0 离职) +- `status`(在岗 / 离职)与 `audit_status`(审核)为两个正交维度:未审核通过的电工不允许在派单下拉 / 推荐中出现 +- 列表:分页 + 关键词(姓名 / 手机号)+ 筛选 `status`、`audit_status`、`certStatus`(资质状态:正常 / 临期 / 已过期 / 无证书) +- 创建:手机号唯一校验;后台创建时可选「同时创建 APP 登录密码」(默认密码 = 手机号后 6 位,不足 6 位用 `123456`,与 `customer_account` 规则一致) +- 更新:字段白名单;手机号变更需重新校验唯一 +- 删除:硬删除,级联删除其 `worker_cert`;若存在派单引用(`survey_order` / `install_order` 的 `worker_id`)则返回 400 并提示 +- 权限:仅 `dispatch:worker` 权限角色(本期仅 ADMIN)可访问 + +### FR-4:电工资质审核流转 + +系统 SHALL 支持电工登记的资质审核状态机。 + +- 状态流转:`0 待审核` → `1 已通过` 或 `2 已驳回`;`2 已驳回` 可重新提交回 `0 待审核`(由后台或电工 APP 触发) +- 后台「审核通过」:写入 `audit_status=1`、`audit_by=当前用户`、`audit_at=now`、清空 `audit_remark` +- 后台「审核驳回」:必须填写 `audit_remark`(驳回理由),写入 `audit_status=2`、审核人 / 时间 +- 电工 APP「重新提交」:仅 `audit_status=2` 时可提交,提交后回到 `0 待审核` 并清空审核人 / 时间 +- `worker.cert_verified` 由审核结果同步:`audit_status=1` 时置 `true`(保持与既有推荐算法字段兼容) + +### FR-5:电工资质证照子表(`worker_cert`) + +系统 SHALL 以子表管理电工的多本资质证照。 + +- 表字段:`id`、`worker_id`(FK → `worker`,NOT NULL,IDX)、`cert_type`(证书类型:低压电工证 / 高压电工证 / 特种作业证 / 登高证 / 其他,取自字典 `worker_cert_type`)、`cert_no`(证书编号)、`issue_authority`(发证机关)、`issue_date`(DATE,发证日期)、`expire_date`(DATE,有效期至,**到期提醒依据**,IDX)、`cert_image_url`(证照图片对象 key,复用 `upload` + `file_record`)、`created_at`、`updated_at` +- 一个电工可挂多本证书;同一电工下 `(cert_type, cert_no)` 组合不可重复(UNIQUE 组合索引) +- 证书到期状态为**计算值**(不落库),由 `expire_date` 与当前日期推导: + - `无证书`:该电工 `worker_cert` 为空 + - `已过期`:存在任一本 `expire_date < today` + - `临期`:无已过期,且存在任一本 `today ≤ expire_date ≤ today + 30 天` + - `正常`:其余(含 `expire_date` 为 NULL 视为长期有效) +- 后台支持在电工编辑弹窗中增删改证书行 +- 电工 APP 支持新增 / 编辑本人证书(新增 / 修改后不改变 `audit_status`,但会触发该证书已生成通知的清理,见 FR-7) + +### FR-6:资质到期提醒(列表 / 仪表盘标签) + +系统 SHALL 在管理端提供可见的资质到期提醒。 + +- 电工登记列表新增「资质状态」列,按 FR-5 计算值渲染标签(正常 = 绿色 / 临期 = 橙色 / 已过期 = 红色 / 无证书 = 灰色) +- 列表顶部提供 KPI 统计卡:电工总数、待审核数、临期数、已过期数 +- 提供「资质临期 / 已过期」快捷筛选(复用 `certStatus` 查询参数) +- 仪表盘新增「电工资质到期提醒」区块,展示临期与已过期证书清单(电工姓名 + 证书类型 + 有效期至 + 剩余天数),点击跳转电工登记页 + +### FR-7:站内通知与定时任务 + +系统 SHALL 通过站内通知触达资质到期提醒。 + +- 新增表 `sys_notice`:`id`、`notice_type`(`cert_expire`)、`title`、`content`、`biz_type`(`worker_cert`)、`biz_id`(`worker_cert.id`)、`receiver_id`(`sys_user.id`,IDX)、`is_read`(0 未读 / 1 已读,默认 0)、`created_at` +- 幂等约束:UNIQUE(`notice_type`, `biz_id`, `receiver_id`);定时任务插入使用 `ON CONFLICT DO NOTHING` +- 定时任务:`@midwayjs/cron` 每日 08:00 执行,扫描 `expire_date <= today + 30 天` 的 `worker_cert`,为每个 ADMIN 用户(`sys_role.role_code = 'ADMIN'` 关联的 `sys_user`)生成一条未读通知;标题形如「电工【张三】的高压电工证将于 2026-10-20 到期(剩余 19 天)」 +- 证书续期(`expire_date` 被更新)或证书被删除时,服务层同步清理该证书既有未读通知,保证续期后可重新提醒 +- 通知接口:`GET /api/notices`(本人,分页)、`GET /api/notices/unread-count`、`PUT /api/notices/:id/read`、`PUT /api/notices/read-all`;仅返回 `receiver_id = 当前用户` 的数据 +- 前端:`layouts/index.vue` 顶栏用户信息左侧新增消息铃铛 + 未读红点角标(轮询或进入页面时拉取未读数),点击打开消息中心抽屉(列表 + 标记已读 + 全部已读 + 点击跳转电工登记页) + +### FR-8:电工 APP 后端接口(`/api/worker-app/*`) + +系统 SHALL 为电工 APP 提供独立的后端接口(本期不改 APP 页面)。 + +- 鉴权方式:独立电工 JWT,payload 含 `workerId`、`phone`、`userType: "worker"`;`TokenUtil` 新增 `generateWorkerToken` / `verifyWorkerToken` +- 新增 `WorkerAuthMiddleware`,`match = ['/api/worker-app/*']`、`ignore = ['/api/worker-app/auth/*']`;校验通过后写入 `ctx.state.worker = { workerId, phone }` +- `rbac.middleware.ts` 与 `auth.middleware.ts` 均忽略 `/api/worker-app/*`(不走管理端 sys_user 鉴权) +- 接口清单: + - `POST /api/worker-app/auth/register`:自助注册(姓名、手机号、身份证号、职衔、密码);手机号唯一;默认 `audit_status=0`、`status=0`(未在岗) + - `POST /api/worker-app/auth/login`:手机号 + 密码登录,返回 worker token;未审核通过允许登录但仅能查看审核状态与资质 + - `GET /api/worker-app/profile`:本人资料 + `audit_status` / `audit_remark` + 资质状态汇总 + - `PUT /api/worker-app/profile`:修改本人可编辑字段(姓名 / 身份证号 / 职衔 / 密码) + - `GET /api/worker-app/certs`、`POST /api/worker-app/certs`、`PUT /api/worker-app/certs/:id`、`DELETE /api/worker-app/certs/:id`:本人证书增删改查(证书图片经 `POST /api/upload/image` 上传后回填 key) + - `POST /api/worker-app/audit/resubmit`:`audit_status=2` 时重新提交审核 + - `GET /api/worker-app/notices`、`PUT /api/worker-app/notices/:id/read`:本人证书到期提醒(`receiver` 维度改为该 worker 的证书) +- 越权控制:所有 `:id` / 证书操作必须校验归属当前 `workerId`,否则 403 + +### FR-9:菜单 / 权限 / 字典 + +系统 SHALL 在种子数据与 RBAC 中补齐新模块。 + +- `运营派单`(id=3)改为目录:`menu_type=1`,`menu_path=/dispatch`,`permission=dispatch:list` +- 新增子菜单(`parent_id=3`,`menu_type=2`): + - `34`「运维费定价」`/dispatch/price` `dispatch/price/index` `dispatch:price` + - `35`「电工派单成本价」`/dispatch/cost` `dispatch/cost/index` `dispatch:cost` + - `36`「电工登记管理」`/dispatch/worker` `dispatch/worker/index` `dispatch:worker` +- 新增按钮权限(`menu_type=3`):`price:create` / `price:update` / `price:delete`、`cost:create` / `cost:update` / `cost:delete`、`worker:create` / `worker:update` / `worker:delete` / `worker:audit` +- 角色绑定:全部绑定给 ADMIN(role_id=1);SALESMAN 不绑定(保持无「运营派单」可见性) +- 新增字典:`om_service_type`、`om_price_mode`、`om_price_unit`、`worker_cert_type`、`worker_audit_status` +- `sys_menu` 序列重置为 36(含按钮权限则为最后一条 id) +- `rbac.middleware.ts` 新增映射:`/api/service-prices → dispatch:price`、`/api/dispatch-prices → dispatch:cost`、`/api/workers → dispatch:worker`;`ignore` 增加 `/api/worker-app/*`、`/api/workers/options`、`/api/workers/recommend` +- `/api/notices` 不做权限码映射(登录即可访问本人通知) + +### FR-10:前端页面 + +系统 SHALL 提供 3 个管理端页面,风格与现有「客户档案管理」页完全一致(搜索栏 + 表格内滚动 + 新增/编辑弹窗 + 删除二次确认 + 状态开关)。 + +- `views/dispatch/price/index.vue`(运维费定价):搜索栏(关键词 / 计价方式 / 服务类型 / 状态)+ 表格(价目编码 / 名称 / 服务类型 / 计价方式 / 单价 / 单位 / 状态 / 操作)+ 弹窗(表单校验、计价方式联动单位) +- `views/dispatch/cost/index.vue`(电工派单成本价):搜索栏(关键词 / 派单类型 / 计价方式 / 状态)+ 表格(编码 / 派单类型 / 价格类型 / 计价方式 / 单价 / 单位 / 状态 / 操作)+ 弹窗 +- `views/dispatch/worker/index.vue`(电工登记管理):KPI 统计卡 + 搜索栏(关键词 / 在岗状态 / 审核状态 / 资质状态)+ 表格(姓名 / 手机号 / 职衔 / 资质状态标签 / 审核状态标签 / 在岗状态 / 评分 / 完成数 / 操作)+ 编辑弹窗(基础信息 + 证书子表行编辑 + 证照图片上传)+ 审核弹窗(通过 / 驳回 + 驳回理由) +- 路由:`/dispatch` 改为重定向 `/dispatch/price`;新增 3 条子路由,`meta.permission` 对应 `dispatch:price` / `dispatch:cost` / `dispatch:worker` +- 页面使用现有 `useTableScroll` composable、`request` 封装与统一返回结构 + +--- + +## Non-Functional Requirements + +### NFR-1:安全 +- 所有查询走 Sequelize 参数化 API(`findAndCountAll` / `create` / `update` / `destroy` + `where` 对象),禁止字符串拼接 SQL;LIKE 使用 `{ [Op.like]: \`%${keyword}%\` }` 参数化 +- 前端不使用 `v-html`;表单输入加长度上限与正则过滤 `<` `>` `"` `'` +- `worker` 列表 / 详情接口永不返回 `password_hash`;`sys_notice` 接口严格按 `receiver_id` 过滤 + +### NFR-2:权限与数据隔离 +- 管理端:`/api/workers`、`/api/service-prices`、`/api/dispatch-prices` 受 `dispatch:*` 权限码控制,ADMIN 直通 +- 电工 APP:所有接口以 `ctx.state.worker.workerId` 为唯一数据边界,禁止通过请求参数越权读取他人数据 +- ADMIN 判定复用现有 `sys_role.role_code = 'ADMIN'` 逻辑 + +### NFR-3:性能 +- 新增表均建必要索引:`worker_cert.worker_id`、`worker_cert.expire_date`、`service_price.price_code`(UNIQUE)、`sys_notice.receiver_id`、`sys_notice.(notice_type,biz_id,receiver_id)`(UNIQUE)、`worker.audit_status` +- 列表接口统一 `limit + offset` 分页;证书聚合按 `worker_id IN (...)` 批量查询,禁止 N+1 +- 单接口响应目标 < 500ms(本地开发环境) + +### NFR-4:类型与一致性 +- Entity / DTO / 前端 API 类型均用显式 interface;Controller 参数使用 DTO class + `class-validator` 装饰器 +- 统一返回结构:列表 `{ code: 200, message, data: { list, total, page, pageSize } }`;单条 / 写操作 `{ code: 200, message, data }`;错误码 401 / 403 / 400 / 404 / 500 + +### NFR-5:数据库变更 +- 新增 `database/migrations/20261001_om_worker_registry.sql`(ALTER / CREATE TABLE / INSERT 菜单字典 / 序列重置),沿用现有 migration 命名与写法 +- 同步修订 `database/db_seed.sql`,保证全新初始化数据库与迁移后结构一致 +- 迁移脚本可重复执行(`IF NOT EXISTS` / `ON CONFLICT` / 有默认值的 NOT NULL 列) + +--- + +## Constraints + +- 技术栈不可更换:Midway.js (Koa) + Sequelize-typescript + PostgreSQL;Vue3 + Ant Design Vue 4 + Vite;JWT + 现有 RBAC 中间件 +- `sequelize.sync = false`(见 `config.default.ts`),建表必须通过 SQL 迁移完成 +- 定时任务需新增依赖 `@midwayjs/cron`(属官方组件,与现有 Midway 3.x 体系一致) +- 本期不做:派单 / 收入结算联动(不写 `worker_income`)、电工评分计算、客户侧查询运维价目的接口、`smart_grid_iot_app` 页面改造 + +## Assumptions(需你复核) + +1. **`price_mode` 三种模式全部开放**:表结构、接口、页面下拉均支持「按次 / 按工时 / 材料」,而不是本期只开放「按次」。理由:这是满足「后续扩展」诉求的最低成本做法。若你希望界面暂只显示「按次」,请告知。 +2. **材料归客户侧**:材料费用只作为客户运维价目的 `price_mode=3`,电工派单成本价目暂不含材料项。 +3. **电工 APP 登录账号 = `worker` 表手机号 + 密码**,与 `sys_user` 完全独立,不新建 `sys_user` 记录;电工 APP 权限不走 RBAC 菜单,接口即权限。 +4. **审核人 = ADMIN**:`audit_by` 记录 `sys_user.id`;本期不区分多级审核。 +5. **通知接收人仅 ADMIN 角色用户**;提前 30 天标「临期」,`expire_date` 为 NULL 视为长期有效不提醒。 +6. **定时任务依赖新增 `@midwayjs/cron`**;若你不希望引入新依赖,可改为 `@Configuration` 生命周期 + `setInterval` 每日检查(功能等价,代码稍脏)。 +7. **电工下拉 / 推荐口径变更**:`/api/workers/options`、`/api/workers/recommend` 增加「`audit_status=1` 且 `status=1`」过滤条件——即只有审核通过且在岗的电工才能被派单。这会改变现有勘察派单页可选电工范围(当前是只要有 `status=1`)。**这是有意的业务收紧,请确认**。 +8. **`dispatch_price` 无存量数据假设**(或存量极少),迁移直接补默认值即可,不需要数据回填脚本。 +9. **菜单 id 从 34 起分配**,`sys_menu` 序列重置到最大值;`db_seed.sql` 原有注释中的菜单计数同步更新。 +10. **管理端列表「资质状态」为实时计算**,不落库;因此 `certStatus` 筛选在 SQL 层用子查询 / `EXISTS` 实现(Sequelize `where` 内联 `fn`/`literal` 参数化写法),不使用字符串拼接。 + +## Acceptance Criteria + +### AC-1:客户运维价目 CRUD 可用 +- **Given**:ADMIN 登录,进入「运营派单 → 运维费定价」 +- **When**:新增一条 `price_mode=1 按次`、`service_type=维修`、单价 200、单位「次」的价目并保存 +- **Then**:返回 code=200,`price_code` 形如 `FW20261001001`;列表刷新可见该记录;编辑改单价后列表更新;删除后列表移除;`price_mode=2/3` 也能正常新增 +- **Verification**:`programmatic` + +### AC-2:电工派单成本价目可用且兼容存量 +- **Given**:`dispatch_price` 表执行迁移后 +- **When**:查询既有数据的 `price_mode` / `status` +- **Then**:存量行 `price_mode=1`、`status=1`(默认值生效,无 NULL);新增 / 编辑 / 删除 / 筛选均正常 +- **Verification**:`programmatic` + +### AC-3:电工资质审核流转正确 +- **Given**:后台新增一名电工并录入 1 本证书(`audit_status=0`) +- **Then**:列表显示「待审核」;执行「审核驳回」未填理由时返回 400;填写理由后 `audit_status=2` 且 `audit_remark` 有值、`audit_by`/`audit_at` 有值;执行「审核通过」后 `audit_status=1`、`cert_verified=true`、`audit_remark` 清空 +- **Verification**:`programmatic` + +### AC-4:资质状态计算与筛选正确 +- **Given**:电工 A 证书 `expire_date = today + 10 天`;电工 B 证书 `expire_date = today - 1 天`;电工 C 无证书;电工 D 证书 `expire_date = today + 100 天` +- **When**:访问电工列表 +- **Then**:A 显示「临期」、B 显示「已过期」、C 显示「无证书」、D 显示「正常」;按 `certStatus=临期` 筛选只返回 A +- **Verification**:`programmatic` + +### AC-5:到期提醒标签与仪表盘区块 +- **Given**:存在 AC-4 场景数据,且 A 为 `audit_status=1`、`status=1` +- **Then**:电工登记页 KPI 显示待审核数 / 临期数 / 已过期数正确;仪表盘「电工资质到期提醒」区块列出 A、B 的证书与剩余天数,点击可跳转电工登记页 +- **Verification**:`human-judgment` + +### AC-6:定时任务生成站内通知且幂等 +- **Given**:存在 `expire_date = today + 19 天` 的证书,且系统有 1 个 ADMIN 用户 +- **When**:手动触发定时任务一次,再触发第二次 +- **Then**:第一次生成 1 条 `sys_notice`(`receiver_id` = ADMIN,`is_read=0`);第二次不新增(UNIQUE + ON CONFLICT DO NOTHING) +- **Verification**:`programmatic` + +### AC-7:消息中心可用 +- **Given**:ADMIN 登录且存在 2 条未读通知 +- **Then**:顶栏铃铛显示红点角标「2」;打开抽屉可见 2 条通知;单条标记已读后角标变「1」;「全部已读」后角标消失;点击通知跳转到电工登记页 +- **Verification**:`human-judgment` + `programmatic`(`GET /api/notices/unread-count`) + +### AC-8:电工 APP 接口可用且数据隔离 +- **Given**:电工 E1、E2 各注册成功并登录 +- **When**:E1 携带自己的 token 调用 `GET /api/worker-app/certs` 与 `PUT /api/worker-app/certs/:id`(传 E2 的证书 id) +- **Then**:只能看到 E1 自己的证书;操作 E2 的证书返回 403;`POST /api/worker-app/audit/resubmit` 在 `audit_status=2` 时可将状态改回 0,`audit_status=1` 时返回 400 +- **Verification**:`programmatic` + +### AC-9:权限与既有页面不受影响 +- **Given**:SALESMAN 账号登录 +- **When**:访问 `/api/workers/options`、`/api/workers/recommend`(项目勘察派单页使用) +- **Then**:正常返回;访问 `/api/workers/list` 或 `/api/service-prices` 返回 403;SALESMAN 侧边栏不出现「运营派单」及其子菜单 +- **Verification**:`programmatic` + `human-judgment` + +### AC-10:敏感字段不外泄 +- **Given**:调用 `GET /api/workers` 与 `GET /api/workers/:id` +- **Then**:返回体不含 `password_hash` +- **Verification**:`programmatic` + +### AC-11:迁移脚本可重复执行 +- **Given**:在已执行过迁移的库上再执行一次 `20261001_om_worker_registry.sql` +- **Then**:无报错,菜单 / 字典数据收敛不重复(`ON CONFLICT` 生效) +- **Verification**:`programmatic` + +## Open Questions + +- [x] (已确认)运维费定价对象:客户收费价目 + 电工派单成本价目,**分开维护** +- [x] (已确认)交付范围:后端 API + 管理端 Web + 电工 APP 后端接口;不含 `smart_grid_iot_app` 页面改造 +- [x] (已确认)资质建模:`worker_cert` 子表 + `worker.audit_status` 审核流转 +- [x] (已确认)到期提醒:列表 / 仪表盘标签 **与** 定时任务站内通知并存 +- [x] (已确认)客户价目建模:单表 `price_mode` 区分 +- [x] (已确认)定价维度:全局统一价目,不按客户 / 等级区分 +- [x] (已确认)菜单位置:「运营派单」下两个独立子菜单(+ 电工登记管理) +- [x] (已确认)电工 APP:自助注册 + 上传资质 + 查审核结果 +- [ ] 待复核:Assumptions 第 7 条「派单可选电工收紧为审核通过 + 在岗」是否接受 +- [ ] 待复核:Assumptions 第 1 条「三种 `price_mode` 是否本期全部开放录入」 \ No newline at end of file diff --git a/.trae/specs/add-om-pricing-and-worker-registry/tasks.md b/.trae/specs/add-om-pricing-and-worker-registry/tasks.md new file mode 100644 index 0000000..2252325 --- /dev/null +++ b/.trae/specs/add-om-pricing-and-worker-registry/tasks.md @@ -0,0 +1,92 @@ +# Tasks + +> change-id: `add-om-pricing-and-worker-registry` +> 依赖顺序:Task 1 → 2 → (3,4,5,6,7 并行) → 8 → 9 → 10 + +- [x] Task 1: 数据库迁移与种子数据(全部后续任务的前置) + - [x] SubTask 1.1: 新建 `database/migrations/20261001_om_worker_registry.sql` + - ALTER `worker` 增加 `password_hash` / `audit_status`(默认 0, NOT NULL) / `audit_remark` / `audit_by` / `audit_at` / `last_login_at` + - CREATE TABLE `worker_cert`(含 `worker_id` IDX、`expire_date` IDX、UNIQUE(`worker_id`,`cert_type`,`cert_no`)) + - CREATE TABLE `service_price`(含 `price_code` UNIQUE、(`price_mode`,`status`) IDX) + - ALTER `dispatch_price` 增加 `price_mode`(默认 1, NOT NULL) / `unit` / `status`(默认 1, NOT NULL) / `remark` / `updated_at` + - CREATE TABLE `sys_notice`(含 `receiver_id` IDX、UNIQUE(`notice_type`,`biz_id`,`receiver_id`)) + - UPDATE `sys_menu` SET `menu_type=1` WHERE id=3;INSERT 菜单 id 34-46(3 个页面菜单 + 10 个按钮权限)+ ADMIN 角色绑定 + `setval('sys_menu_id_seq', …)` + - INSERT 新字典:`om_service_type` / `om_price_mode` / `om_price_unit` / `worker_cert_type` / `worker_audit_status` + - 全部语句使用 `IF NOT EXISTS` / `ON CONFLICT`,保证可重复执行 + - [x] SubTask 1.2: 同步修订 `database/db_seed.sql`(菜单 / 字典 / 角色绑定与迁移脚本一致,更新末尾计数注释) + - [x] SubTask 1.3: 执行迁移脚本验证可跑通且可重复执行 + +- [x] Task 2: 后端实体层 + - [x] SubTask 2.1: 修改 `server/src/entity/worker.entity.ts`(补 6 个字段 + 索引) + - [x] SubTask 2.2: 修改 `server/src/entity/dispatch_price.entity.ts`(补 5 个字段,开启 `updated_at`) + - [x] SubTask 2.3: 新建 `server/src/entity/worker_cert.entity.ts` + - [x] SubTask 2.4: 新建 `server/src/entity/service_price.entity.ts` + - [x] SubTask 2.5: 新建 `server/src/entity/sys_notice.entity.ts` + +- [x] Task 3: 客户运维价目后端(依赖 Task 2) + - [x] SubTask 3.1: 新建 `dto/service_price.dto.ts`(创建 / 更新 / 查询 DTO + `class-validator` 校验) + - [x] SubTask 3.2: 新建 `service/service_price.service.ts`(分页搜索筛选、`price_code` 生成 `FW+YYYYMMDD+3位`、白名单更新、增删改) + - [x] SubTask 3.3: 新建 `controller/service_price.controller.ts`(`/api/service-prices`,统一返回结构) + +- [x] Task 4: 电工派单成本价目后端(依赖 Task 2) + - [x] SubTask 4.1: 新建 `dto/dispatch_price.dto.ts` + - [x] SubTask 4.2: 新建 `service/dispatch_price.service.ts`(分页 + 关键词 + `dispatch_type`/`price_mode`/`status` 筛选 + CRUD + `price_code` 唯一冲突友好提示) + - [x] SubTask 4.3: 新建 `controller/dispatch_price.controller.ts`(`/api/dispatch-prices`) + +- [x] Task 5: 电工登记与资质审核后端(依赖 Task 2) + - [x] SubTask 5.1: 扩展 `dto/worker.dto.ts`(登记创建 / 更新 / 审核 / 列表查询 / 证书行 DTO) + - [x] SubTask 5.2: 扩展 `service/worker.service.ts` + - 列表分页 + 关键词 + `status`/`auditStatus`/`certStatus` 筛选,批量聚合证书计算「资质状态」(避免 N+1) + - KPI 统计(总数 / 待审核 / 临期 / 已过期) + - 详情(含证书列表) + - 创建(手机号唯一、`audit_status=0`、可选生成默认密码 bcrypt)、更新、删除(级联删证书 + 派单引用校验) + - 审核通过 / 驳回(校验驳回理由必填、写 `audit_by`/`audit_at`/`cert_verified`) + - 证书行增删改(续期 / 删除时清理该证书未读通知) + - 保持 `/options`、`/recommend` 现有逻辑并按 Assumption 7 收紧为 `audit_status=1 AND status=1` + - [x] SubTask 5.3: 扩展 `controller/worker.controller.ts`(`/api/workers` 增补 list/detail/create/update/delete/audit/certs 接口,静态路径在动态路径之前) + +- [x] Task 6: 站内通知后端 + 定时任务(依赖 Task 1、2) + - [x] SubTask 6.1: `server/package.json` 新增依赖 `@midwayjs/cron` 并安装 + - [x] SubTask 6.2: 新建 `service/notice.service.ts`(本人通知分页、未读数、单条 / 全部已读、ADMIN 用户查询、证书到期扫描 + 幂等生成、按证书清理通知) + - [x] SubTask 6.3: 新建 `controller/notice.controller.ts`(`/api/notices`、`/api/notices/unread-count`、`/api/notices/:id/read`、`/api/notices/read-all`) + - [x] SubTask 6.4: 新建 `task/cert_expire.task.ts`(`@Cron` 每日 08:00,调用 `notice.service` 扫描生成) + +- [x] Task 7: 电工 APP 后端接口(依赖 Task 5) + - [x] SubTask 7.1: 扩展 `util/token.ts`(`generateWorkerToken` / `verifyWorkerToken`) + - [x] SubTask 7.2: 新建 `middleware/worker_auth.middleware.ts`(`match=['/api/worker-app/*']`、`ignore=['/api/worker-app/auth/*']`,写 `ctx.state.worker`) + - [x] SubTask 7.3: 修改 `middleware/auth.middleware.ts` 与 `middleware/rbac.middleware.ts`(忽略 `/api/worker-app/*`;新增 `/api/service-prices`、`/api/dispatch-prices`、`/api/workers` 权限映射;`ignore` 增加白名单 `/api/workers/options`、`/api/workers/recommend`) + - [x] SubTask 7.4: 新建 `dto/worker_app.dto.ts`、`service/worker_app.service.ts`、`controller/worker_app.controller.ts`(注册 / 登录 / 资料 / 证书 CRUD / 重新提交审核 / 通知,全部以 `workerId` 为数据边界) + +- [x] Task 8: 前端 API 层(依赖 Task 3、4、5、6) + - [x] SubTask 8.1: 新建 `client/src/api/servicePrice.ts` + - [x] SubTask 8.2: 新建 `client/src/api/dispatchPrice.ts` + - [x] SubTask 8.3: 新建 `client/src/api/workerRegistry.ts`(登记 / 审核 / 证书 / KPI 统计) + - [x] SubTask 8.4: 新建 `client/src/api/notice.ts` + +- [x] Task 9: 前端页面与路由(依赖 Task 8) + - [x] SubTask 9.1: 新建 `client/src/views/dispatch/price/index.vue`(运维费定价) + - [x] SubTask 9.2: 新建 `client/src/views/dispatch/cost/index.vue`(电工派单成本价) + - [x] SubTask 9.3: 新建 `client/src/views/dispatch/worker/index.vue`(电工登记管理:KPI 卡 + 列表 + 编辑弹窗含证书子表与图片上传 + 审核弹窗) + - [x] SubTask 9.4: 修改 `client/src/router/index.ts`(`/dispatch` 重定向到 `/dispatch/price`,新增 3 条子路由与 `meta.permission`) + +- [x] Task 10: 仪表盘提醒区块与消息中心(依赖 Task 8) + - [x] SubTask 10.1: 修改 `client/src/layouts/index.vue`(消息铃铛 + 未读红点角标 + 消息中心抽屉 + 跳转) + - [x] SubTask 10.2: 修改 `client/src/views/dashboard/index.vue`(新增「电工资质到期提醒」区块,展示临期 / 已过期证书清单并可跳转) + +- [x] Task 11: 联调与验证 + - [x] SubTask 11.1: 后端 `yarn dev` 启动无编译错误,逐条走通 AC-1 ~ AC-11 + - [x] SubTask 11.2: 前端 `yarn dev` 启动,人工验证 AC-5、AC-7、AC-9(菜单可见性) + - [x] SubTask 11.3: 人工触发定时任务验证 AC-6 幂等 + +- [x] Task 12: 修复验证阶段发现的阻断性缺陷(Task 11 追加) + - [x] SubTask 12.1: 修复 `WorkerAuthMiddleware` 同时声明 `match` 与 `ignore` 导致 Midway 抛 `MIDWAY_10001`、**全站接口 500** 的问题(改为仅 `match`,登录/注册路径在 `resolve()` 内放行) + +# Task Dependencies + +- Task 2 依赖 Task 1(表结构必须先存在) +- Task 3 / Task 4 / Task 5 依赖 Task 2,彼此无依赖,可并行 +- Task 6 依赖 Task 1、Task 2 +- Task 7 依赖 Task 5(复用 worker 服务)与 Task 6(通知复用) +- Task 8 依赖 Task 3、4、5、6(接口契约已定) +- Task 9、Task 10 依赖 Task 8,彼此无依赖,可并行 +- Task 11 依赖全部 \ No newline at end of file diff --git a/.trae/specs/add-scheduled-job-management/checklist.md b/.trae/specs/add-scheduled-job-management/checklist.md new file mode 100644 index 0000000..16c38fd --- /dev/null +++ b/.trae/specs/add-scheduled-job-management/checklist.md @@ -0,0 +1,64 @@ +# Checklist + +## 数据库与种子 + +- [x] `database/migrations/20261002_job_management.sql` 已创建,建 `sys_job` 与 `sys_job_log` 两表及索引,可重复执行无报错 +- [x] `sys_job` 含 `job_key` 唯一索引与 `status` 索引;`params` 为 JSONB 且默认 `{}` +- [x] `sys_job_log` 含 `(job_id, created_at DESC)` 与 `created_at` 索引 +- [x] 迁移写入菜单 id 47(定时任务,parent_id=6,`/system/job`,`system:job`,menu_type=2,sort_order=6)与 48/49/50 三个按钮权限(parent_id=47,menu_type=3) +- [x] ADMIN 角色(role_id=1)已绑定菜单 47-50;`sys_menu_id_seq` 被设为 50 +- [x] 迁移写入 `demo_heartbeat`(`*/5 * * * *`)与 `log_clean`(`0 3 * * *`,retry_times=2、retry_interval=60)两条 `sys_job` 种子,重复执行不产生重复行 +- [x] `database/db_seed.sql` 已同步菜单 47-50、ADMIN 绑定、2 条 `sys_job` 种子与 `sys_menu_id_seq`=50;新库初始化与迁移后结构一致 +- [x] 既有菜单 id 7-11 的名称、路径、排序未被改动 + +## 后端 + +- [x] `server/package.json` 已显式声明 `cron` 依赖,`yarn dev` 可正常编译启动 +- [x] 内置任务注册表 `server/src/task/job_registry.ts` 提供 `jobKey/jobName/description/defaultCron/defaultParams/paramFields/handler`,页面参数表单由 `paramFields` 驱动,无需前端硬编码 +- [x] `demo_heartbeat` 与 `log_clean` 两个内置任务均已注册,`job_key` 与种子数据一致 +- [x] 调度器启动加载全部启用任务,停用任务不被调度 +- [x] 调度初始化失败时不阻塞应用启动(try/catch + 最多 3 次重试),`onStop` 停止全部定时器 +- [x] 页面修改 cron 后无需重启即按新表达式触发,列表 `nextRunTime` 同步更新 +- [x] 提交非法 cron 表达式返回 400 且提示明确,配置未落库 +- [x] 停用任务后定时触发停止,`nextRunTime` 为 `null`/`-`,但仍可手动执行 +- [x] 同一任务执行中再次触发时写 `status = 3` 跳过日志且不并发执行 +- [x] 失败且 `attempt <= retry_times` 时按 `retry_interval` 秒后以 `trigger_type = 3` 重试,`attempt` 递增 +- [x] `sys_job.last_run_at / last_run_status / last_run_cost_ms / last_error` 每次执行后正确回写(成功时清空 `last_error`) +- [x] `log_clean` 同时清理 `sys_operation_log` 与 `sys_job_log`,摘要含两张表删除条数,默认 `keepDays = 90` +- [x] `keepDays` 越界(如 0 或 5000)返回 400,配置未落库 +- [x] `GET /api/jobs` 支持任务名称模糊与状态筛选,返回分页结构并携带 `paramFields`、`nextRunTime`、上次执行信息 +- [x] `PUT /api/jobs/:id`、`PUT /api/jobs/:id/status`、`POST /api/jobs/:id/run`、`GET /api/jobs/:id/logs`、`DELETE /api/jobs/logs/clear` 均可用且返回统一结构 +- [x] 静态路由 `/api/jobs/logs/clear` 未被 `:id` 动态路由吞掉 +- [x] `error_stack` 落库被截断至 4000 字符,`message` 摘要可读 +- [x] 未注册的 `job_key` 在页面标记为未注册,编辑/启停/执行返回 400「任务实现不存在」 +- [x] `PERMISSION_MAP` 已新增 `'/api/jobs': 'system:job'`,非 ADMIN 无该权限时访问返回 403 +- [x] 所有查询/更新走 Sequelize 参数化 API,无字符串拼接 SQL 与模板字符串 LIKE +- [x] 控制器入参使用 DTO + `class-validator`,未滥用 `any`(除既有约定处) +- [x] 列表接口均使用 `limit + offset` 分页,无全表扫描式查询 +- [x] 调度器为进程内单例(`@Scope(ScopeEnum.Singleton)`),保证「启动注册」与「接口重载」操作同一个定时器注册表 + +## 前端 + +- [x] `client/src/api/job.ts` 提供 6 个接口方法与 TS 类型 +- [x] `client/src/views/system/job/index.vue` 含搜索栏 + 表格 + 编辑弹窗 + 立即执行二次确认 + 执行日志抽屉 +- [x] 表格列包含任务名/键、cron、参数摘要、状态开关、上次执行时间与状态与耗时、下次执行时间、重试配置 +- [x] 编辑弹窗中 cron、参数、重试次数、重试间隔可编辑,且保存前后端校验失败时弹窗保留并提示 message +- [x] 执行日志抽屉支持分页、状态筛选与「清理日志」二次确认(默认清理 90 天前) +- [x] 路由 `/system/job` 已注册,`meta.permission = 'system:job'` +- [x] 侧边栏「系统管理」下可见「定时任务」(ADMIN),非 ADMIN 访问跳转 403 +- [x] 页面未使用 `v-html`,cron 与参数输入含长度/范围校验 +- [x] 页面结构与既有页面(操作日志 / 数据字典)保持一致,未引入多余 UI 元素 + +## 联调 + +- [x] 后端 `yarn dev` 启动无编译错误,接口逐条通过 +- [x] 前端 `yarn dev` 启动,编辑 → 立即执行 → 查看日志 → 清理日志 全链路可用 +- [x] 改 cron 即时生效与启停即时生效已验证 +- [x] 重试机制(`trigger_type = 3`、`attempt` 递增)已验证 +- [x] 迁移脚本与 `db_seed.sql` 在空库与存量库两端结果一致 +- [x] 隔离单实例复测:`*/1` 每分钟恰好触发 1 次、停用后 0 次触发、手动执行正常、恢复后 `nextRunTime` 正确(见 tasks.md Task 8) + +## 遗留说明(非阻塞) + +- `sys_job.job_key` 同时存在列级 `UNIQUE` 自动生成的 `sys_job_job_key_key` 与显式 `uk_sys_job_key`,两个索引功能重复(冗余但无害,不影响验收) +- 非法入参返回的是响应体 `code=400`,HTTP 状态保持 200,与仓库既有 26 个控制器的约定一致 diff --git a/.trae/specs/add-scheduled-job-management/spec.md b/.trae/specs/add-scheduled-job-management/spec.md new file mode 100644 index 0000000..ee3cd10 --- /dev/null +++ b/.trae/specs/add-scheduled-job-management/spec.md @@ -0,0 +1,322 @@ +# 定时任务管理 Spec + +## Why + +系统已存在多处需要周期性执行的后台作业(月度物化视图刷新、日志清理,后续还有 ICC 数据拉取、电工资质到期扫描),但当前调度能力分散在数据库 `pg_cron` 与应用内 `@midwayjs/cron` 注解中:运维无法在管理端查看任务、启停、调整执行频次,也无法追溯「什么时候执行过、成功还是失败」。需要在管理端提供统一的定时任务管理能力。 + +## What Changes + +- 新增 2 张表:`sys_job`(任务定义与调度配置)、`sys_job_log`(执行日志) +- 后端新增「内置任务注册表」+ 自研轻量调度器:任务清单由代码注册表定义,cron / 参数 / 启停 / 重试配置持久化在 DB,页面修改后**即时生效**(无需重启) +- 新增 2 个内置任务:`demo_heartbeat`(示例心跳,用于验证链路)、`log_clean`(清理操作日志 + 任务日志,默认保留 90 天) +- 新增接口 `/api/jobs` 系列:列表、更新配置、启停、立即执行一次、执行日志分页、手动清理日志 +- 系统管理下新增「定时任务」菜单(`sys_menu.id = 47`)+ 3 个按钮权限(48-50) +- 前端新增页面 `client/src/views/system/job/index.vue`:任务列表 + 编辑弹窗 + 执行日志抽屉,并在路由注册 +- 不修改现有表结构与现有接口(`/api/logs/clear` 手工清空日志能力保留) + +## Impact + +- Affected specs:系统管理(RBAC 菜单/权限)、操作日志(新增自动清理来源) +- Affected code: + - 后端:`server/src/entity/`、`server/src/service/`、`server/src/controller/`、`server/src/dto/`、`server/src/task/`、`server/src/configuration.ts`、`server/src/middleware/rbac.middleware.ts`、`server/package.json` + - 前端:`client/src/api/`、`client/src/views/system/job/`、`client/src/router/index.ts` + - 数据库:`database/migrations/20261002_job_management.sql`、`database/db_seed.sql` + +### 已确认决策(来自需求澄清) + +1. 形态:**内置任务管理**——任务清单由代码定义,页面只做启停 / 改 cron / 改参数 / 立即执行 / 看日志;不做「页面任意填写调用目标」的通用调度中心 +2. 调度引擎:**自研轻量调度器**(配置持久化在 DB,进程内调度),不使用 `@midwayjs/cron` 注解做静态任务 +3. 首批任务:框架 + 示例任务 + 清理类任务;**ICC 用电数据采集本期不纳入** +4. 执行日志:独立日志表 + 页面分页查询 + 手动清理 +5. 参数:**有限参数可编辑**(按任务注册表声明的字段白名单渲染表单,不开放任意 JSON) +6. 失败处理:支持重试,**重试次数与重试间隔均可按任务配置** +7. 日志保留期:操作日志 + 任务日志均默认 90 天,保留天数作为任务参数可在页面调整 + +### 假设(如与预期不符请在开工前指出) + +1. **单实例部署**:并发控制使用进程内重入锁,不实现分布式锁(多实例部署时同一任务会在每个实例各触发一次)。这与初始选项描述中的「Redis 锁」有偏差:当前代码库 `server/src` 内没有任何 Redis/ioredis 使用点(仅 `config.default.ts` 声明了 redis 配置),引入会使任务运行强依赖 Redis 可用性。 +2. **不做执行超时强制中断**(Node 侧无法安全中断同步逻辑),仅在日志中记录执行耗时。 +3. **立即执行接口同步返回执行结果**(请求内等待任务跑完),适配本期两个秒级内置任务;后续接入长耗时任务时再改异步触发。 +4. **权限粒度**:仅新增菜单级权限 `system:job`(后端 RBAC 真实生效);3 个按钮权限码只用于前端按钮显隐,后端不做按钮级校验。 +5. **不提供任务新增/删除**:任务清单由代码注册表决定,页面无删除入口。 +6. **与并行规格共存**:`add-om-pricing-and-worker-registry` 规格正在并行实现,其中用 `@midwayjs/cron` 注解实现了电工资质到期扫描(`server/src/task/cert_expire.task.ts`)。本期**不改造**它,两套机制并存;后续可把该任务迁入本任务注册表统一管理(本期非目标)。 +7. **无独立 UI 设计稿**:页面沿用现有约定(搜索栏 + 表格 + 新增/编辑弹窗),与「操作日志」「数据字典」页一致。 + +### 非目标(本期不做) + +- 通用调度中心(页面填写 Bean/方法名调用目标、动态执行任意代码) +- ICC 用电数据采集任务(接口未定,后续单独开规格) +- 任务编排/依赖、任务分组、任务执行结果通知推送 +- 多实例分布式互斥、任务超时强制中断、任务优先级与队列 +- 把 `pg_cron` 中的分区维护/物化视图刷新任务迁移到应用侧 +- 改造 `@midwayjs/cron` 注解任务(`cert_expire.task.ts`) + +--- + +## 技术约定(本期实现必须遵守) + +- 技术栈不变:Midway.js 3.x (Koa) + Sequelize-typescript + PostgreSQL;Vue3 + Ant Design Vue 4 + Vite +- `sequelize.sync = false`(见 [config.default.ts](file:///d:/MyProject/智能电网/smart_grid_iot/server/src/config/config.default.ts#L26-L52)),建表必须通过 `database/migrations/*.sql` 完成,并同步 `database/db_seed.sql` +- cron 解析复用项目已安装的 `cron` 包(`node_modules/cron@3.5.0`,为 `@midwayjs/cron` 的传递依赖,已在 `server/yarn.lock` 中):在 `server/package.json` 中**显式声明** `"cron": "^3.5.0"` 后使用,不引入其它新依赖 +- 所有 SQL 走 Sequelize 参数化 API,禁止字符串拼接 +- 所有列表接口使用 `limit + offset` 分页;控制器入参使用 DTO + `class-validator` +- 统一返回结构 `{ code, message, data }`,与现有 service 一致 +- 前端不使用 `v-html` + +--- + +## ADDED Requirements + +### Requirement: 任务定义与调度配置持久化(`sys_job`) + +系统 SHALL 提供 `sys_job` 表持久化内置任务的调度配置。 + +字段: + +| 列 | 类型 | 说明 | +| --- | --- | --- | +| id | BIGSERIAL PK | 自增主键 | +| job_key | VARCHAR(64) NOT NULL UNIQUE | 任务键,与代码注册表一一对应(如 `log_clean`) | +| job_name | VARCHAR(64) NOT NULL | 任务名称 | +| cron_expression | VARCHAR(64) NOT NULL | cron 表达式(分 时 日 月 周,支持 6 段带秒) | +| params | JSONB NOT NULL DEFAULT '{}' | 任务参数(受白名单字段约束) | +| retry_times | SMALLINT NOT NULL DEFAULT 0 | 失败后最大重试次数(0-5) | +| retry_interval | INT NOT NULL DEFAULT 60 | 失败后重试间隔(秒,10-3600) | +| status | SMALLINT NOT NULL DEFAULT 1 | 1 启用 / 0 停用 | +| remark | VARCHAR(255) | 备注 | +| last_run_at | TIMESTAMPTZ | 上次执行时间 | +| last_run_status | SMALLINT | 1 成功 / 2 失败 / 3 跳过 | +| last_run_cost_ms | INT | 上次执行耗时(毫秒) | +| last_error | TEXT | 上次失败原因(成功时清空) | +| created_at / updated_at | TIMESTAMPTZ | 时间戳 | + +索引:`sys_job(job_key)` 唯一索引、`sys_job(status)` 普通索引。 + +#### Scenario: 新库初始化与存量库迁移后结构一致 +- **WHEN** 在空库执行 `db_init.sql` + `db_seed.sql`,或在存量库执行 `database/migrations/20261002_job_management.sql` +- **THEN** `sys_job` 表存在且含上述全部列与索引,且迁移脚本可重复执行无报错 + +#### Scenario: 内置任务种子数据 +- **WHEN** 种子数据/迁移执行完成 +- **THEN** `sys_job` 中存在 `demo_heartbeat` 与 `log_clean` 两条启用状态记录,重复执行不产生重复行(按 `job_key` 幂等) + +--- + +### Requirement: 内置任务注册表 + +系统 SHALL 通过代码注册表声明内置任务,注册表条目包含:`jobKey`、`jobName`、`description`、`defaultCron`、`defaultParams`、`paramFields`(可编辑参数白名单:key / label / 类型 / 最小值 / 最大值 / 默认值)、`handler(params, context)`。 + +#### Scenario: 新增内置任务 +- **WHEN** 开发者在注册表中新增一个任务条目并在迁移/种子中插入对应 `sys_job` 行 +- **THEN** 页面列表自动出现该任务,无需改动前端代码(参数表单由 `paramFields` 驱动渲染) + +#### Scenario: job_key 未注册 +- **WHEN** 列表中的任务 `job_key` 在注册表中不存在 +- **THEN** 列表仍展示该行但标记为「未注册」,编辑/启停/立即执行均返回 400 并提示「任务实现不存在」 + +--- + +### Requirement: 自研轻量调度器(动态生效) + +系统 SHALL 在应用启动时加载全部 `status = 1` 的 `sys_job` 并注册调度;页面修改 cron / 启停后无需重启即生效。 + +- 调度器 SHALL 使用 `cron` 包的 `CronJob` 承载定时触发,调度时区固定 `Asia/Shanghai` +- 调度器 SHALL 在接收到配置变更(更新/启停)后,对单个任务执行 `stop → 重新注册 → start`(`reload(jobKey)`) +- 调度器启动 SHALL 自行捕获异常并重试(最多 3 次、间隔 5 秒),不得因调度初始化失败阻塞应用启动 +- 应用停止(`onStop`)SHALL 停止全部定时器 +- 列表接口 SHALL 返回由 cron 表达式即时计算的 `nextRunTime`(停用或表达式非法时为 `null`) +- 修改 cron 表达式时 SHALL 先校验表达式合法性,非法则返回 400「cron 表达式不合法」 + +#### Scenario: 启动加载 +- **WHEN** 应用启动完成且 `sys_job` 中存在启用任务 +- **THEN** 启用任务按各自 cron 被调度执行;停用任务不被调度 + +#### Scenario: 页面改 cron 即时生效 +- **WHEN** 某启用任务的 cron 由 `0 3 * * *` 改为 `*/1 * * * *` 并保存 +- **THEN** 无需重启,约 1 分钟内按新表达式触发执行,列表「下次执行时间」同步更新 + +#### Scenario: 停用任务 +- **WHEN** 任务被停用 +- **THEN** 定时触发停止,列表「下次执行时间」为 `-`;仍可手动「立即执行」 + +#### Scenario: 保存非法 cron +- **WHEN** 提交 `0 3 * *`(字段不足)或非法字符 +- **THEN** 接口返回 400 且 message 明确提示表达式不合法,配置未被写入 + +--- + +### Requirement: 任务执行、并发控制与可配置重试 + +系统 SHALL 按「一次执行一条日志」的粒度记录任务执行,并对同一任务做重入保护。 + +- 同一 `job_key` 正在执行时再次被触发:本次**跳过**,写一条 `status = 3`(跳过)日志,`message` 为「上一次执行尚未结束,本次跳过」,不执行 handler +- 每次执行 SHALL 记录:任务、触发方式(1 定时自动 / 2 手动触发 / 3 失败重试)、尝试次数 `attempt`、开始/结束时间、耗时、状态、结果摘要、异常堆栈(截断存储) +- 执行失败时:若 `attempt <= retry_times`,则在 `retry_interval` 秒后以「失败重试」触发方式再次执行(`attempt + 1`),重试期间该任务仍视为执行中;超出重试次数则本次执行以失败收尾 +- 执行完成后 SHALL 回写任务的 `last_run_at` / `last_run_status` / `last_run_cost_ms` / `last_error` + +#### Scenario: 执行成功 +- **WHEN** 任务执行完成且无异常 +- **THEN** 生成一条 `status = 1` 日志,`cost_ms` 有值,`sys_job.last_run_status = 1` 且 `last_error` 被清空 + +#### Scenario: 失败按配置重试 +- **WHEN** 任务 `retry_times = 2`、`retry_interval = 30`,首次执行抛异常 +- **THEN** 30 秒后自动重试(日志 `trigger_type = 3`、`attempt = 2`),最多重试到 `attempt = 3`;全部失败后 `sys_job.last_run_status = 2` 并记录最后一次错误 + +#### Scenario: 并发跳过 +- **WHEN** 上一次执行未结束时该任务被再次触发 +- **THEN** 不并发执行,新增一条 `status = 3` 的跳过日志 + +--- + +### Requirement: 立即执行一次 + +系统 SHALL 支持在页面手动触发任务。 + +- `POST /api/jobs/:id/run` SHALL 同步执行任务并返回本次执行结果(状态、耗时、摘要/错误) +- 手动执行不受任务启停状态限制(停用任务也可手动执行) + +#### Scenario: 手动执行成功 +- **WHEN** 对停用任务点击「立即执行」 +- **THEN** 接口返回 200 与执行结果,新增一条 `trigger_type = 2` 的日志,列表「上次执行」信息刷新 + +#### Scenario: 手动执行失败 +- **WHEN** 手动执行时任务抛异常且已配置重试 +- **THEN** 与定时触发一致地按配置重试,接口最终返回失败结果与错误信息 + +--- + +### Requirement: 执行日志持久化与查询(`sys_job_log`) + +系统 SHALL 提供 `sys_job_log` 表记录执行日志,并提供分页查询与手动清理。 + +字段:`id`、`job_id`、`job_key`、`job_name`、`trigger_type`、`status`、`attempt`、`started_at`、`finished_at`、`cost_ms`、`message`(TEXT)、`error_stack`(TEXT,截断至 4000 字符)、`created_at`;索引:`sys_job_log(job_id, created_at DESC)`、`sys_job_log(created_at)`。 + +- `GET /api/jobs/:id/logs`:分页,支持 `page`、`pageSize`、`status`、`triggerType`、`startDate`、`endDate` 筛选 +- `DELETE /api/jobs/logs/clear?days=90`:清理早于 `days` 天的任务日志,返回删除条数(与现有 `/api/logs/clear` 风格一致) + +#### Scenario: 日志分页查询 +- **WHEN** 在页面日志抽屉中按状态「失败」筛选 +- **THEN** 仅返回该任务 `status = 2` 的日志,按 `created_at` 倒序分页 + +#### Scenario: 手动清理日志 +- **WHEN** 调用清理接口且 `days = 90` +- **THEN** 早于 90 天的任务日志被删除,返回删除条数,当前日志不受影响 + +--- + +### Requirement: 内置任务「示例心跳」(`demo_heartbeat`) + +系统 SHALL 内置一个用于验证调度链路的示例任务:默认 cron 为 `*/5 * * * *`(每 5 分钟),默认参数 `{ "message": "heartbeat" }`,handler 写入一条 `info` 级日志并以摘要形式返回本次心跳时间。该任务默认启用。 + +#### Scenario: 心跳按周期执行 +- **WHEN** 应用启动且该任务处于启用状态 +- **THEN** 每 5 分钟产生一条成功执行日志,可在页面日志抽屉中查到 + +--- + +### Requirement: 内置任务「日志清理」(`log_clean`) + +系统 SHALL 内置日志清理任务:默认 cron 为 `0 3 * * *`(每日 03:00),默认参数 `{ "keepDays": 90 }`,保留天数可在页面调整(白名单参数,范围 1-3650)。 + +handler SHALL 同时清理: +1. `sys_operation_log` 中早于 `keepDays` 的记录(复用 `LogService.cleanExpiredLogs`) +2. `sys_job_log` 中早于 `keepDays` 的记录 + +执行结果摘要 SHALL 形如「清理操作日志 12 条、任务日志 3 条」。 + +#### Scenario: 按保留天数清理 +- **WHEN** 任务以参数 `keepDays = 90` 执行 +- **THEN** 两张日志表中 90 天前的记录被删除,摘要中分别给出删除条数 + +#### Scenario: 参数越界校验 +- **WHEN** 页面把 `keepDays` 改为 `0` 或 `5000` +- **THEN** 接口返回 400,提示取值范围(1-3650),配置未被写入 + +--- + +### Requirement: 菜单与权限 + +系统 SHALL 在「系统管理」下新增定时任务菜单与按钮权限: + +| id | parent_id | 名称 | 路径 | 组件 | 权限码 | 类型 | +| --- | --- | --- | --- | --- | --- | --- | +| 47 | 6 | 定时任务 | `/system/job` | `system/job/index` | `system:job` | 2 菜单 | +| 48 | 47 | 编辑定时任务 | NULL | NULL | `system:job:update` | 3 按钮 | +| 49 | 47 | 立即执行 | NULL | NULL | `system:job:run` | 3 按钮 | +| 50 | 47 | 清理日志 | NULL | NULL | `system:job:log:clear` | 3 按钮 | + +- 菜单 id 从 47 起,避开 `add-om-pricing-and-worker-registry` 已占用的 34-46 +- ADMIN 角色(`sys_role.id = 1`)SHALL 绑定上述 4 个菜单;`sys_menu_id_seq` SHALL 重设为 50 +- `PERMISSION_MAP` SHALL 新增 `'/api/jobs': 'system:job'` + +#### Scenario: 菜单可见性 +- **WHEN** 以 `admin` 登录 +- **THEN** 侧边栏「系统管理」下可见「定时任务」,点击进入 `/system/job` + +#### Scenario: 权限拦截 +- **WHEN** 非 ADMIN 且未被授予 `system:job` 的用户访问 `/api/jobs` +- **THEN** 返回 403 + +--- + +### Requirement: 前端「定时任务」页面 + +系统 SHALL 提供 `client/src/views/system/job/index.vue`,路由 `/system/job`(`name: SystemJob`,`meta.permission = 'system:job'`),交互包含: + +1. **筛选区**:任务名称(模糊)、状态(启用/停用) +2. **任务表格**:任务名称、任务键、cron 表达式、参数摘要、状态开关、上次执行时间/状态/耗时、下次执行时间、重试配置(次数 × 间隔)、操作列 +3. **编辑弹窗**:cron 表达式、按 `paramFields` 渲染的参数表单项、重试次数、重试间隔、备注;保存前做前端必填与范围校验 +4. **立即执行**:二次确认后调用执行接口,成功/失败以 `message` 提示,随后刷新列表与日志 +5. **执行日志抽屉**:分页表格(触发方式、状态、尝试次数、耗时、开始时间、摘要/错误),支持按状态筛选与「清理日志」按钮(需二次确认,默认清理 90 天前) + +#### Scenario: 编辑并保存 cron +- **WHEN** 在编辑弹窗修改 cron 与参数并保存 +- **THEN** 弹窗关闭、列表刷新显示新值,后端返回错误(如非法 cron / 参数越界)时弹窗保留并提示 message + +#### Scenario: 查看执行日志 +- **WHEN** 点击某任务的「执行日志」 +- **THEN** 抽屉打开并加载该任务日志首页,可翻页与筛选 + +#### Scenario: 前端未使用 v-html +- **WHEN** 检查页面实现 +- **THEN** 无 `v-html`,cron 与参数输入均有长度/范围校验 + +--- + +## MODIFIED Requirements + +### Requirement: RBAC 路径权限映射 + +[`server/src/middleware/rbac.middleware.ts`](file:///d:/MyProject/智能电网/smart_grid_iot/server/src/middleware/rbac.middleware.ts#L10-L29) 的 `PERMISSION_MAP` 在保持既有映射不变的前提下,新增: + +``` +'/api/jobs': 'system:job', +``` + +字典 key 顺序需保证更具体的前缀优先匹配(`/api/jobs` 与其子路径共用同一权限码,无冲突)。 + +### Requirement: 系统管理菜单 + +「系统管理」(`sys_menu.id = 6`)子菜单由 5 个扩展为 6 个,新增 id = 47 的「定时任务」;新增菜单不得改变既有 id 7-11 的名称、路径、排序(1-5),新菜单 `sort_order = 6`。 + +--- + +## REMOVED Requirements + +无。 + +--- + +## 关键文件清单(供实现参考) + +| 层 | 文件 | +| --- | --- | +| 迁移 | `database/migrations/20261002_job_management.sql` | +| 种子 | `database/db_seed.sql`(新增菜单 47-50、ADMIN 绑定、2 条 `sys_job` 种子、`sys_menu_id_seq` → 50) | +| 实体 | `server/src/entity/sys_job.entity.ts`、`server/src/entity/sys_job_log.entity.ts` | +| 注册表 | `server/src/task/job_registry.ts`(内置任务声明)、`server/src/task/demo_heartbeat.task.ts`、`server/src/task/log_clean.task.ts` | +| 调度器 | `server/src/service/job_scheduler.service.ts` | +| 业务 | `server/src/service/job.service.ts` | +| 接口 | `server/src/controller/job.controller.ts`、`server/src/dto/job.dto.ts` | +| 装配 | `server/src/configuration.ts`(onReady 启动调度、onStop 停止调度)、`server/src/middleware/rbac.middleware.ts`、`server/package.json`(显式声明 `cron`) | +| 前端 | `client/src/api/job.ts`、`client/src/views/system/job/index.vue`、`client/src/router/index.ts` | diff --git a/.trae/specs/add-scheduled-job-management/tasks.md b/.trae/specs/add-scheduled-job-management/tasks.md new file mode 100644 index 0000000..f735e30 --- /dev/null +++ b/.trae/specs/add-scheduled-job-management/tasks.md @@ -0,0 +1,76 @@ +# Tasks + +> 规格:`.trae/specs/add-scheduled-job-management/spec.md` +> 说明:任务清单中「内置任务注册表」是本规格的核心抽象,Task 4 依赖它;Task 5-7 可并行。 + +- [x] Task 1: 数据库迁移与种子数据(全部后端任务的前置) + - [x] SubTask 1.1: 新建 `database/migrations/20261002_job_management.sql`:创建 `sys_job`(含 `job_key` 唯一索引、`status` 索引)与 `sys_job_log`(含 `(job_id, created_at DESC)`、`created_at` 索引)两张表,风格对齐现有迁移(注释、幂等 `IF NOT EXISTS`) + - [x] SubTask 1.2: 同一迁移内写入菜单:`sys_menu` 新增 id 47(定时任务,parent_id=6,menu_path `/system/job`,component `system/job/index`,permission `system:job`,menu_type=2,sort_order=6)、48/49/50(按钮权限 `system:job:update` / `system:job:run` / `system:job:log:clear`,parent_id=47,menu_type=3),使用 `ON CONFLICT (id) DO UPDATE` 幂等;为 ADMIN 角色(role_id=1)插入 `sys_role_menu` 绑定;`setval('sys_menu_id_seq', 50, true)` + - [x] SubTask 1.3: 同一迁移内写入 `sys_job` 种子:`demo_heartbeat`(`*/5 * * * *`,params `{"message":"heartbeat"}`,status=1)与 `log_clean`(`0 3 * * *`,params `{"keepDays":90}`,status=1,retry_times=2、retry_interval=60),使用 `ON CONFLICT (job_key) DO NOTHING` + - [x] SubTask 1.4: 同步修订 `database/db_seed.sql`:在现有菜单段落之后追加 47-50 菜单与 ADMIN 绑定、2 条 `sys_job` 种子,更新 `sys_menu_id_seq` 为 50 与文件头部注释中的菜单计数 + - [x] SubTask 1.5: 执行迁移脚本两次验证幂等(第二次无报错、无重复菜单/任务行) + +- [x] Task 2: 实体层 + - [x] SubTask 2.1: 新建 `server/src/entity/sys_job.entity.ts`(列与 spec 中 `sys_job` 定义一致,`params` 用 `DataType.JSONB`,`timestamps: true`,字段名用 `field` 显式声明 snake_case,参考 `sysOperationLog.entity.ts` 风格) + - [x] SubTask 2.2: 新建 `server/src/entity/sys_job_log.entity.ts`(`timestamps: true` 且仅 `created_at`,与 `sysOperationLog` 一致) + +- [x] Task 3: 内置任务注册表与两个内置任务(依赖 Task 2) + - [x] SubTask 3.1: `server/package.json` 显式声明 `"cron": "^3.5.0"` 并执行 `yarn install`(该版本已在 `node_modules` 与 `yarn.lock` 中存在,来自 `@midwayjs/cron` 传递依赖) + - [x] SubTask 3.2: 新建 `server/src/task/job_registry.ts`:定义 `BuiltinJob` 类型(jobKey / jobName / description / defaultCron / defaultParams / paramFields / handler)与 `BUILTIN_JOBS` 数组、`getBuiltinJob(jobKey)`;`paramFields` 支持 `number` 类型与 min/max + - [x] SubTask 3.3: 新建 `server/src/task/demo_heartbeat.task.ts`:`jobKey = demo_heartbeat`,默认 cron `*/5 * * * *`,参数 `message`(string,可选),handler 写 info 日志并返回「心跳时间」摘要 + - [x] SubTask 3.4: 新建 `server/src/task/log_clean.task.ts`:`jobKey = log_clean`,默认 cron `0 3 * * *`,参数 `keepDays`(number,1-3650,默认 90),handler 调用 `LogService.cleanExpiredLogs(keepDays)` 清理 `sys_operation_log`,再删除 `sys_job_log` 中早于 `keepDays` 的记录,返回「清理操作日志 X 条、任务日志 Y 条」 + - [x] SubTask 3.5: 在 `job_registry.ts` 中把两个任务注册进 `BUILTIN_JOBS` + +- [x] Task 4: 调度器(依赖 Task 2、3) + - [x] SubTask 4.1: 新建 `server/src/service/job_scheduler.service.ts`: + - `start()`:加载 `status=1` 的 `sys_job` → 按 `job_key` 匹配注册表 → 用 `cron` 包 `CronJob`(timezone `Asia/Shanghai`)注册并 `start()`;内部 try/catch + 最多 3 次重试(间隔 5s),失败仅记录日志不阻塞启动 + - `stop()`:停止全部定时器(应用停止时调用) + - `reload(jobKey)`:停旧 → 按最新配置重新注册(停用则不注册) + - `runOnce(jobKey, triggerType)`:导出给 controller 的立即执行入口,返回执行结果 + - `nextRunTime(cronExpression)`:用 cron 表达式计算下次执行时间,非法或空返回 `null` + - `validateCron(expression)`:校验表达式合法性 + - [x] SubTask 4.2: 实现执行内核:进程内 `Set` 重入锁 → 未注册任务返回「任务实现不存在」→ 写入 `sys_job_log`(`trigger_type`、`attempt`、`started_at`)→ 执行 handler → 回写日志 `status`/`finished_at`/`cost_ms`/`message`/`error_stack`(截断 4000 字符)→ 回写 `sys_job` 的 `last_run_at`/`last_run_status`/`last_run_cost_ms`/`last_error`;并发触发时写 `status = 3` 跳过日志 + - [x] SubTask 4.3: 实现失败重试:`attempt <= retry_times` 时用 `setTimeout(retry_interval * 1000)` 以 `trigger_type = 3`、`attempt + 1` 再次执行,重试期间保持重入锁;重试链结束仍失败则以 `last_run_status = 2` 收尾 + - [x] SubTask 4.4: 修改 `server/src/configuration.ts`:`onReady` 中获取并 `start()` 调度服务(捕获异常),`onStop` 中 `stop()`;保持不变更其它已有逻辑 + +- [x] Task 5: 业务服务与接口(依赖 Task 4) + - [x] SubTask 5.1: 新建 `server/src/dto/job.dto.ts`:`UpdateJobDTO`(cronExpression、params、retryTimes、retryInterval、remark,`class-validator` 校验)、`JobLogQueryDTO`(page/pageSize/status/triggerType/startDate/endDate) + - [x] SubTask 5.2: 新建 `server/src/service/job.service.ts`: + - `list(query)`:按任务名称模糊 + 状态筛选,返回 `list/total/page/pageSize`,每行附带注册表信息(`jobName`、`description`、`paramFields`、`registered`)、`nextRunTime`(由调度器计算)、上次执行信息 + - `update(id, dto)`:校验存在性与已注册 → 校验 cron 合法性 → 按 `paramFields` 白名单与范围校验 params(越界返回 400)→ 保存 → `scheduler.reload(jobKey)` + - `changeStatus(id, status)`:切换启停 → `scheduler.reload(jobKey)` + - `run(id)`:调用 `scheduler.runOnce(jobKey, 2)` + - `listLogs(id, query)`:分页 + 状态/触发方式/时间范围筛选,`created_at DESC` + - `clearLogs(days = 90)`:删除早于 `days` 天的 `sys_job_log` + - [x] SubTask 5.3: 新建 `server/src/controller/job.controller.ts`(`/api/jobs`,注意静态路径写在动态路径之前): + - `GET /api/jobs`、`PUT /api/jobs/:id`、`PUT /api/jobs/:id/status`、`POST /api/jobs/:id/run` + - `GET /api/jobs/:id/logs`、`DELETE /api/jobs/logs/clear` + - 返回统一 `{ code, message, data }`,400/404 场景给出明确 message + - [x] SubTask 5.4: 修改 `server/src/middleware/rbac.middleware.ts`:`PERMISSION_MAP` 新增 `'/api/jobs': 'system:job'` + +- [x] Task 6: 前端 API 与页面(依赖 Task 5) + - [x] SubTask 6.1: 新建 `client/src/api/job.ts`(`getJobList` / `updateJob` / `changeJobStatus` / `runJob` / `getJobLogs` / `clearJobLogs`,含 TS 类型,参考 `api/log.ts` 风格) + - [x] SubTask 6.2: 新建 `client/src/views/system/job/index.vue`:搜索栏(任务名称、状态)+ 任务表格(任务名/键、cron、参数摘要、状态开关、上次执行时间与状态与耗时、下次执行时间、重试配置、操作)+ 编辑弹窗(cron、按 `paramFields` 动态渲染的参数项、重试次数、重试间隔、备注)+ 立即执行二次确认 + 执行日志抽屉(分页表格、状态筛选、清理日志按钮),保持与 `views/log/operation/index.vue` 一致的页面结构 + - [x] SubTask 6.3: 修改 `client/src/router/index.ts`:在系统管理下新增路由 `system/job`(`name: SystemJob`,`meta: { requiresAuth: true, title: '定时任务', permission: 'system:job' }`) + +- [x] Task 7: 联调与验证(依赖 Task 1-6) + - [x] SubTask 7.1: 后端 `yarn dev` 启动无编译错误;`curl` 验证列表/更新/启停/立即执行/日志查询/日志清理接口 + - [x] SubTask 7.2: 将 `demo_heartbeat` 改为 `*/1 * * * *` 验证「改 cron 即时生效」与「下次执行时间」更新;停用后验证不再触发、恢复启用后重新调度(首轮验证失败 → 见 Task 8;修复后复测:每分钟恰好触发 1 次、停用后 0 次触发、手动执行正常、恢复后 `nextRunTime` 正确) + - [x] SubTask 7.3: 验证重试:临时构造失败场景(如 `log_clean` 传入非法参数不可行时,用 `retry_times`/`retry_interval` 观测日志 `trigger_type = 3` 与 `attempt` 递增) + - [x] SubTask 7.4: 前端 `yarn dev` 启动,人工走通「编辑 → 立即执行 → 查看日志 → 清理日志」全链路,并确认侧边栏「系统管理 → 定时任务」可见(实测:侧边栏菜单可见、`/system/job` 正常进入、两行任务渲染正确、编辑保存成功、立即执行二次确认与成功提示、日志抽屉含手动触发的记录并带分页、清理日志二次确认成功;新标签页控制台无 error) + - [x] SubTask 7.5: 确认迁移脚本与 `db_seed.sql` 在空库/存量库两端结果一致,且 `sys_menu_id_seq` 为 50 + +- [x] Task 8: 修复调度器作用域缺陷(Task 7.2 首轮验证发现的真实缺陷) + - [x] SubTask 8.1: 定位根因 —— `@Provide()` 在本项目 Midway 版本中默认解析为 **Request 作用域**(`@midwayjs/core` 的 `container.bind()` 对 class 目标亦执行 `definition.scope = options?.scope || ScopeEnum.Request`),导致「启动注册定时器」与「接口 `reload`」落在不同服务实例上:`reload` 所在实例的 `timers` 为空,既停不掉旧定时器,又新建一个定时器,每次保存/启停都泄漏一个常驻定时器(实测同一分钟内出现 2 次以上重复触发,且停用后仍持续触发) + - [x] SubTask 8.2: 为 `JobSchedulerService` 显式声明 `@Scope(ScopeEnum.Singleton)`,使定时器注册表在启动注册与接口重载之间共享(`@InjectRepository` 由单例数据源管理器解析,无需请求上下文,可安全用于单例) + - [x] SubTask 8.3: `LogService` 同为请求作用域,单例中不可直接 `@Inject`;改为在 `buildRuntime()` 内通过 `this.app.createAnonymousContext().requestContext.getAsync(LogService)` 按需获取(对齐 `@midwayjs/cron` 官方执行任务的上下文用法),注入 `@App()` 替换原 `@Inject() logService` + - [x] SubTask 8.4: 清理定位期间加入的临时诊断日志,`npx tsc --noEmit` 通过,并在隔离单实例(7009)下复测 7.2 全部通过 + +# Task Dependencies + +- Task 2 依赖 Task 1(表结构存在后才能通过实体读写) +- Task 3 依赖 Task 2;Task 4 依赖 Task 2、3 +- Task 5 依赖 Task 4;Task 6 依赖 Task 5 +- Task 7 依赖 Task 1-6 +- Task 8 依赖 Task 7.2 的首轮失败验证;Task 8 完成后需回归 Task 7.2 +- 并行建议:Task 1 完成后,Task 2/3 与前端 Task 6.1(API 层)可并行;Task 5 与 Task 6.2/6.3 可在接口契约冻结后并行 diff --git a/client/src/api/dispatchPrice.ts b/client/src/api/dispatchPrice.ts new file mode 100644 index 0000000..50c51df --- /dev/null +++ b/client/src/api/dispatchPrice.ts @@ -0,0 +1,61 @@ +import request from '@/utils/request'; + +export interface DispatchPriceInfo { + id: number; + priceCode: string; + dispatchType: string; + priceType?: string; + priceMode: number; + unitPrice?: number; + unit?: string; + status: number; + remark?: string; + createdAt: string; + updatedAt: string; +} + +export interface DispatchPriceQuery { + page?: number; + pageSize?: number; + keyword?: string; + dispatchType?: string; + priceMode?: number; + status?: number; +} + +export interface DispatchPriceListResult { + list: DispatchPriceInfo[]; + total: number; + page: number; + pageSize: number; +} + +export interface DispatchPricePayload { + dispatchType?: string; + priceType?: string; + priceMode?: number; + unitPrice?: number; + unit?: string; + status?: number; + remark?: string; +} + +export const dispatchPriceApi = { + getDispatchPriceList: (params: DispatchPriceQuery) => + request.get('/dispatch-prices', { params }), + + getDispatchPriceDetail: (id: number) => + request.get(`/dispatch-prices/${id}`), + + createDispatchPrice: (data: DispatchPricePayload) => + request.post('/dispatch-prices', data), + + updateDispatchPrice: (id: number, data: DispatchPricePayload) => + request.put(`/dispatch-prices/${id}`, data), + + deleteDispatchPrice: (id: number) => + request.delete(`/dispatch-prices/${id}`), + + updateDispatchPriceStatus: (id: number, status: number) => + request.put(`/dispatch-prices/${id}/status`, { status }), +}; \ No newline at end of file diff --git a/client/src/api/job.ts b/client/src/api/job.ts new file mode 100644 index 0000000..a4ff7a1 --- /dev/null +++ b/client/src/api/job.ts @@ -0,0 +1,113 @@ +import request from '@/utils/request'; + +export interface JobParamField { + key: string; + label: string; + type: 'number' | 'string'; + min?: number; + max?: number; + placeholder?: string; +} + +export interface JobItem { + id: number; + jobKey: string; + jobName: string; + cronExpression: string; + params: Record; + retryTimes: number; + retryInterval: number; + status: number; + remark?: string; + lastRunAt?: string | null; + lastRunStatus?: number | null; + lastRunCostMs?: number | null; + lastError?: string | null; + createdAt?: string; + updatedAt?: string; + description?: string; + paramFields?: JobParamField[]; + registered: boolean; + nextRunTime?: string | null; +} + +export interface JobLogItem { + id: number; + jobId: number; + jobKey: string; + jobName: string; + triggerType: number; + status: number; + attempt: number; + startedAt?: string | null; + finishedAt?: string | null; + costMs?: number | null; + message?: string | null; + errorStack?: string | null; + createdAt?: string | null; +} + +export interface JobRunResult { + status: number; + message: string; + costMs: number; + attempt: number; +} + +export interface JobQuery { + page?: number; + pageSize?: number; + jobName?: string; + status?: number; +} + +export interface JobLogQuery { + page?: number; + pageSize?: number; + status?: number; + triggerType?: number; + startDate?: string; + endDate?: string; +} + +export interface UpdateJobPayload { + cronExpression: string; + params: Record; + retryTimes: number; + retryInterval: number; + remark?: string; +} + +export interface JobListResult { + list: JobItem[]; + total: number; + page: number; + pageSize: number; +} + +export interface JobLogListResult { + list: JobLogItem[]; + total: number; + page: number; + pageSize: number; +} + +export const jobApi = { + getJobList: (params: JobQuery) => + request.get('/jobs', { params }), + + updateJob: (id: number, data: UpdateJobPayload) => + request.put(`/jobs/${id}`, data), + + changeJobStatus: (id: number, status: 0 | 1) => + request.put(`/jobs/${id}/status`, { status }), + + runJob: (id: number) => + request.post(`/jobs/${id}/run`), + + getJobLogs: (id: number, params: JobLogQuery) => + request.get(`/jobs/${id}/logs`, { params }), + + clearJobLogs: (days = 90) => + request.delete<{ deleted: number }>('/jobs/logs/clear', { params: { days } }), +}; diff --git a/client/src/api/notice.ts b/client/src/api/notice.ts new file mode 100644 index 0000000..acb0a2c --- /dev/null +++ b/client/src/api/notice.ts @@ -0,0 +1,42 @@ +import request from '@/utils/request'; + +export interface NoticeInfo { + id: number; + noticeType: string; + title: string; + content?: string; + bizType?: string; + bizId?: number; + // 是否已读:0 未读 / 1 已读 + isRead: number; + createdAt?: string; + created_at?: string; +} + +export interface NoticeQuery { + page?: number; + pageSize?: number; + isRead?: number; +} + +export interface NoticeListResult { + list: NoticeInfo[]; + total: number; + page: number; + pageSize: number; +} + +export interface NoticeUnreadCount { + count: number; +} + +// 站内通知(消息中心) +export const noticeApi = { + getList: (params: NoticeQuery) => request.get('/notices', { params }), + + getUnreadCount: () => request.get('/notices/unread-count'), + + readAll: () => request.put('/notices/read-all'), + + markRead: (id: number) => request.put(`/notices/${id}/read`), +}; \ No newline at end of file diff --git a/client/src/api/servicePrice.ts b/client/src/api/servicePrice.ts new file mode 100644 index 0000000..6072814 --- /dev/null +++ b/client/src/api/servicePrice.ts @@ -0,0 +1,61 @@ +import request from '@/utils/request'; + +export interface ServicePriceInfo { + id: number; + priceCode: string; + priceName: string; + serviceType?: string; + priceMode: number; + unitPrice: number; + unit?: string; + status: number; + remark?: string; + createdAt: string; + updatedAt: string; +} + +export interface ServicePriceQuery { + page?: number; + pageSize?: number; + keyword?: string; + priceMode?: number; + serviceType?: string; + status?: number; +} + +export interface ServicePriceListResult { + list: ServicePriceInfo[]; + total: number; + page: number; + pageSize: number; +} + +export interface ServicePricePayload { + priceName?: string; + serviceType?: string; + priceMode?: number; + unitPrice?: number; + unit?: string; + status?: number; + remark?: string; +} + +export const servicePriceApi = { + getServicePriceList: (params: ServicePriceQuery) => + request.get('/service-prices', { params }), + + getServicePriceDetail: (id: number) => + request.get(`/service-prices/${id}`), + + createServicePrice: (data: ServicePricePayload) => + request.post('/service-prices', data), + + updateServicePrice: (id: number, data: ServicePricePayload) => + request.put(`/service-prices/${id}`, data), + + deleteServicePrice: (id: number) => + request.delete(`/service-prices/${id}`), + + updateServicePriceStatus: (id: number, status: number) => + request.put(`/service-prices/${id}/status`, { status }), +}; \ No newline at end of file diff --git a/client/src/api/workerRegistry.ts b/client/src/api/workerRegistry.ts new file mode 100644 index 0000000..e7fffa3 --- /dev/null +++ b/client/src/api/workerRegistry.ts @@ -0,0 +1,128 @@ +import request from '@/utils/request'; + +// 资质状态:none 无证书 / normal 正常 / expiring 临期 / expired 已过期 +export type WorkerCertStatus = 'none' | 'normal' | 'expiring' | 'expired'; + +// 审核状态:0 待审核 / 1 已通过 / 2 已驳回 +export type WorkerAuditStatus = 0 | 1 | 2; + +// 证书行(详情接口返回完整证书,保存时按此结构提交) +export interface WorkerCert { + id?: number; + workerId?: number; + certType: string; + certNo: string; + issueAuthority?: string; + issueDate?: string; + expireDate?: string; + certImageUrl?: string; +} + +// 列表接口返回的证书摘要 +export interface WorkerCertSummary { + id: number; + certType: string; + certNo: string; + expireDate?: string; +} + +export interface WorkerRecord { + id: number; + workerName: string; + phone?: string; + idCard?: string; + workerTitle?: string; + rating?: number | string; + certInfo?: string; + certVerified?: boolean; + completedCount?: number; + // 在岗状态:1 在岗 / 0 离职 + status: number; + auditStatus: number; + auditRemark?: string; + auditBy?: number; + auditAt?: string; + lastLoginAt?: string; + certStatus?: WorkerCertStatus; + certs?: WorkerCertSummary[]; + createdAt?: string; + created_at?: string; + updatedAt?: string; + updated_at?: string; +} + +export interface WorkerQuery { + page?: number; + pageSize?: number; + keyword?: string; + status?: number; + auditStatus?: number; + certStatus?: WorkerCertStatus; +} + +export interface WorkerListResult { + list: WorkerRecord[]; + total: number; + page: number; + pageSize: number; +} + +// KPI:电工总数 / 待审核 / 临期 / 已过期 +export interface WorkerStats { + total: number; + pendingAudit: number; + expiring: number; + expired: number; +} + +export interface WorkerCertPayload { + certType: string; + certNo: string; + issueAuthority?: string; + issueDate?: string; + expireDate?: string; + certImageUrl?: string; +} + +export interface WorkerPayload { + workerName: string; + phone: string; + idCard?: string; + workerTitle?: string; + status?: number; + rating?: number; + certInfo?: string; + // 新增时是否同时开通电工 APP 账号(默认密码为手机号后 6 位) + withAccount?: boolean; + // 存在时整体覆盖该电工的全部证书 + certs?: WorkerCertPayload[]; +} + +export interface WorkerAuditPayload { + approved: boolean; + remark?: string; +} + +// 管理端电工登记接口(不修改原 worker.ts,勘察派单页仍使用 workerApi) +export const workerRegistryApi = { + getList: (params: WorkerQuery) => request.get('/workers', { params }), + + getStats: () => request.get('/workers/stats'), + + getDetail: (id: number) => request.get(`/workers/${id}`), + + create: (data: WorkerPayload) => request.post('/workers', data), + + update: (id: number, data: Partial) => request.put(`/workers/${id}`, data), + + remove: (id: number) => request.delete(`/workers/${id}`), + + audit: (id: number, data: WorkerAuditPayload) => request.put(`/workers/${id}/audit`, data), + + addCert: (id: number, data: WorkerCertPayload) => request.post(`/workers/${id}/certs`, data), + + updateCert: (id: number, certId: number, data: WorkerCertPayload) => + request.put(`/workers/${id}/certs/${certId}`, data), + + removeCert: (id: number, certId: number) => request.delete(`/workers/${id}/certs/${certId}`), +}; \ No newline at end of file diff --git a/client/src/layouts/index.vue b/client/src/layouts/index.vue index 359148d..8d47432 100644 --- a/client/src/layouts/index.vue +++ b/client/src/layouts/index.vue @@ -48,6 +48,13 @@
+ + +
+ +
+
+
+ + +
+
+
+
+
+

电工资质到期提醒

+ CERT ALERT +
+

临期(30 天内)与已过期的电工资质证照清单,已过期优先展示

+
+ + 查看全部 + + +
+ +
+ + + + + + + + + + + + + + + + + +
电工姓名证书类型有效期至到期状态
{{ row.workerName }}{{ row.certType }}{{ row.expireDate }} + + {{ row.expired ? `已过期 ${Math.abs(row.days)} 天` : `剩余 ${row.days} 天` }} + +
+
暂无临期或已过期的电工资质
+
+
+
+ + \ No newline at end of file diff --git a/client/src/views/dispatch/price/index.vue b/client/src/views/dispatch/price/index.vue new file mode 100644 index 0000000..1c5b3d7 --- /dev/null +++ b/client/src/views/dispatch/price/index.vue @@ -0,0 +1,871 @@ + + + + + \ No newline at end of file diff --git a/client/src/views/dispatch/worker/index.vue b/client/src/views/dispatch/worker/index.vue new file mode 100644 index 0000000..0be4d4b --- /dev/null +++ b/client/src/views/dispatch/worker/index.vue @@ -0,0 +1,1539 @@ + + + + + \ No newline at end of file diff --git a/client/src/views/system/job/index.vue b/client/src/views/system/job/index.vue new file mode 100644 index 0000000..1d4974d --- /dev/null +++ b/client/src/views/system/job/index.vue @@ -0,0 +1,649 @@ + + + + + diff --git a/database/db_seed.sql b/database/db_seed.sql index 2140a02..ca1ed7c 100644 --- a/database/db_seed.sql +++ b/database/db_seed.sql @@ -17,7 +17,7 @@ -- -- 内容: -- 1. 初始角色(ADMIN / SALESMAN / WORKER / CUSTOMER) --- 2. 初始菜单(顶级 6 + 系统管理子菜单 5) +-- 2. 初始菜单(顶级 6 + 系统管理子菜单 6 + 运营派单子菜单 13 等,共 50 条) -- 3. 初始字典(客户/设备/派单/支付等业务字典) -- 4. 管理员账户(admin / admin123456) -- 5. 用户-角色绑定 / 角色-菜单绑定 @@ -43,12 +43,12 @@ ON CONFLICT (id) DO UPDATE SET SELECT setval('sys_role_id_seq', 4, true); -- ------------------------------------------------------------ --- 2. 初始菜单(顶级菜单,显式指定 id:1-6,系统管理为目录类型 menu_type=1,其余为 menu_type=2) +-- 2. 初始菜单(顶级菜单,显式指定 id:1-6,系统管理/运营派单为目录类型 menu_type=1,其余为 menu_type=2) -- ------------------------------------------------------------ INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES (1, NULL, '客户管理', '/customer', NULL, 'TeamOutlined', 1, 1, 'customer:list', 1), (2, NULL, '项目管理', '/project', NULL, 'ProjectOutlined', 2, 1, 'project:list', 1), -(3, NULL, '运营派单', '/dispatch', NULL, 'ScheduleOutlined', 3, 2, 'dispatch:list', 1), +(3, NULL, '运营派单', '/dispatch', NULL, 'ScheduleOutlined', 3, 1, 'dispatch:list', 1), (4, NULL, '设备监控', '/device', NULL, 'MonitorOutlined', 4, 2, 'device:list', 1), (5, NULL, '统计报表', '/report', NULL, 'BarChartOutlined', 5, 2, 'report:list', 1), (6, NULL, '系统管理', '/system', NULL, 'SettingOutlined', 6, 1, 'system:list', 1) @@ -132,8 +132,34 @@ ON CONFLICT (id) DO UPDATE SET permission = EXCLUDED.permission, visible = EXCLUDED.visible; +-- 运营派单子菜单(parent_id=3,id:34-36 为菜单项,37-46 为按钮权限) +INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES +(34, 3, '运维费定价', '/dispatch/price', 'dispatch/price/index', 'MoneyCollectOutlined', 1, 2, 'dispatch:price', 1), +(35, 3, '电工派单成本价', '/dispatch/cost', 'dispatch/cost/index', 'AccountBookOutlined', 2, 2, 'dispatch:cost', 1), +(36, 3, '电工登记管理', '/dispatch/worker', 'dispatch/worker/index', 'ToolOutlined', 3, 2, 'dispatch:worker', 1), +(37, 3, '新增价目', NULL, NULL, NULL, 1, 3, 'price:create', 1), +(38, 3, '编辑价目', NULL, NULL, NULL, 2, 3, 'price:update', 1), +(39, 3, '删除价目', NULL, NULL, NULL, 3, 3, 'price:delete', 1), +(40, 3, '新增成本价', NULL, NULL, NULL, 4, 3, 'cost:create', 1), +(41, 3, '编辑成本价', NULL, NULL, NULL, 5, 3, 'cost:update', 1), +(42, 3, '删除成本价', NULL, NULL, NULL, 6, 3, 'cost:delete', 1), +(43, 3, '新增电工', NULL, NULL, NULL, 7, 3, 'worker:create', 1), +(44, 3, '编辑电工', NULL, NULL, NULL, 8, 3, 'worker:update', 1), +(45, 3, '删除电工', NULL, NULL, NULL, 9, 3, 'worker:delete', 1), +(46, 3, '审核电工', NULL, NULL, NULL, 10, 3, 'worker:audit', 1) +ON CONFLICT (id) DO UPDATE SET + parent_id = EXCLUDED.parent_id, + menu_name = EXCLUDED.menu_name, + menu_path = EXCLUDED.menu_path, + component = EXCLUDED.component, + icon = EXCLUDED.icon, + sort_order = EXCLUDED.sort_order, + menu_type = EXCLUDED.menu_type, + permission = EXCLUDED.permission, + visible = EXCLUDED.visible; + -- 重置 sys_menu 自增序列 -SELECT setval('sys_menu_id_seq', 33, true); +SELECT setval('sys_menu_id_seq', 50, true); -- ------------------------------------------------------------ -- 3. 初始字典(sys_dict 无业务唯一键,清空重建以支持幂等执行) @@ -162,7 +188,28 @@ INSERT INTO sys_dict (dict_type, dict_label, dict_value, sort_order) VALUES ('dispatch_type', '安装派单', 'install', 2), ('dispatch_type', '报警派单', 'alarm', 3), ('dispatch_type', '维修派单', 'repair', 4), -('dispatch_type', '维保派单', 'maintenance', 5); +('dispatch_type', '维保派单', 'maintenance', 5), +('om_service_type', '维修', '维修', 1), +('om_service_type', '维保', '维保', 2), +('om_service_type', '报警处理', '报警处理', 3), +('om_service_type', '巡检', '巡检', 4), +('om_service_type', '其他', '其他', 5), +('om_price_mode', '按次', '1', 1), +('om_price_mode', '按工时', '2', 2), +('om_price_mode', '材料', '3', 3), +('om_price_unit', '次', '次', 1), +('om_price_unit', '小时', '小时', 2), +('om_price_unit', '个', '个', 3), +('om_price_unit', '米', '米', 4), +('om_price_unit', '根', '根', 5), +('worker_cert_type', '低压电工证', '低压电工证', 1), +('worker_cert_type', '高压电工证', '高压电工证', 2), +('worker_cert_type', '特种作业证', '特种作业证', 3), +('worker_cert_type', '登高证', '登高证', 4), +('worker_cert_type', '其他', '其他', 5), +('worker_audit_status', '待审核', '0', 1), +('worker_audit_status', '已通过', '1', 2), +('worker_audit_status', '已驳回', '2', 3); -- ------------------------------------------------------------ -- 4. 管理员账户(显式指定 id=1,密码哈希为 bcrypt('admin123456')) @@ -188,13 +235,14 @@ INSERT INTO sys_user_role (user_id, role_id) VALUES (1, 1) ON CONFLICT (user_id, role_id) DO NOTHING; -- ------------------------------------------------------------ --- 6. 角色-菜单绑定:ADMIN 拥有全部 32 条菜单,SALESMAN 拥有客户域+项目+统计报表 +-- 6. 角色-菜单绑定:ADMIN 拥有全部 46 条菜单,SALESMAN 拥有客户域+项目+统计报表 -- WORKER、CUSTOMER 不绑定任何菜单 -- ------------------------------------------------------------ INSERT INTO sys_role_menu (role_id, menu_id) VALUES (1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), (1, 7), (1, 8), (1, 9), (1, 10), (1, 11), (1, 12), (1, 13), (1, 14), (1, 15), (1, 16), (1, 17), (1, 18), (1, 19), (1, 20), (1, 21), (1, 22), (1, 23), (1, 24), (1, 27), (1, 28), (1, 29), (1, 30), (1, 31), (1, 32), (1, 33), +(1, 34), (1, 35), (1, 36), (1, 37), (1, 38), (1, 39), (1, 40), (1, 41), (1, 42), (1, 43), (1, 44), (1, 45), (1, 46), (2, 1), (2, 2), (2, 5), (2, 12), (2, 13), (2, 14), (2, 15), (2, 16), (2, 17), (2, 18), (2, 19), (2, 20), (2, 21), (2, 22), (2, 23), (2, 24), (2, 27), (2, 28), (2, 29), (2, 30), (2, 31), (2, 32), (2, 33) @@ -204,8 +252,42 @@ ON CONFLICT (role_id, menu_id) DO NOTHING; DELETE FROM sys_role_menu WHERE menu_id IN (25, 26); DELETE FROM sys_menu WHERE id IN (25, 26); +-- ------------------------------------------------------------ +-- 7. 定时任务域菜单(系统管理 parent_id=6,id:47 为菜单项,48-50 为按钮权限) +-- ------------------------------------------------------------ +INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES +(47, 6, '定时任务', '/system/job', 'system/job/index', 'ClockCircleOutlined', 6, 2, 'system:job', 1), +(48, 47, '编辑定时任务', NULL, NULL, NULL, 1, 3, 'system:job:update', 1), +(49, 47, '立即执行', NULL, NULL, NULL, 2, 3, 'system:job:run', 1), +(50, 47, '清理日志', NULL, NULL, NULL, 3, 3, 'system:job:log:clear', 1) +ON CONFLICT (id) DO UPDATE SET + parent_id = EXCLUDED.parent_id, + menu_name = EXCLUDED.menu_name, + menu_path = EXCLUDED.menu_path, + component = EXCLUDED.component, + icon = EXCLUDED.icon, + sort_order = EXCLUDED.sort_order, + menu_type = EXCLUDED.menu_type, + permission = EXCLUDED.permission, + visible = EXCLUDED.visible; + +-- 角色-菜单绑定:ADMIN(role_id=1)拥有 47-50 +INSERT INTO sys_role_menu (role_id, menu_id) VALUES +(1, 47), (1, 48), (1, 49), (1, 50) +ON CONFLICT (role_id, menu_id) DO NOTHING; + +-- ------------------------------------------------------------ +-- 8. 定时任务种子(sys_job,按 job_key 幂等) +-- ------------------------------------------------------------ +INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES +('demo_heartbeat', '示例心跳', '*/5 * * * *', '{"message":"heartbeat"}'::jsonb, 0, 60, 1, + '示例心跳任务:用于验证调度链路,默认每 5 分钟写入一条 info 日志'), +('log_clean', '日志清理', '0 3 * * *', '{"keepDays":90}'::jsonb, 2, 60, 1, + '日志清理任务:每日 03:00 清理早于 keepDays 天的操作日志与任务日志,默认保留 90 天') +ON CONFLICT (job_key) DO NOTHING; + COMMIT; -- ============================================================ --- 完 · 7 张系统表种子数据(4 角色 + 32 菜单 + 22 字典 + 1 管理员 + 绑定关系) +-- 完 · 系统表种子数据(4 角色 + 50 菜单 + 43 字典 + 1 管理员 + 2 定时任务 + 绑定关系) -- ============================================================ diff --git a/database/migrations/20261001_om_worker_registry.sql b/database/migrations/20261001_om_worker_registry.sql new file mode 100644 index 0000000..a6343e1 --- /dev/null +++ b/database/migrations/20261001_om_worker_registry.sql @@ -0,0 +1,221 @@ +-- 运维费定价 + 电工登记管理功能扩展 +-- 背景:运营派单域由「派单执行」扩展为「客户运维服务价目 + 电工派单成本价 + 电工登记审核」, +-- 对应新增 service_price(客户运维服务价目)、worker_cert(电工资质证照)、 +-- sys_notice(证照到期站内通知)三张表,并为 worker / dispatch_price 补齐 +-- 登录、审核、计价方式等业务字段,同时挂载运营派单子菜单与按钮权限。 +-- 幂等:新增列一律带 DEFAULT + IF NOT EXISTS;建表/索引用 IF NOT EXISTS; +-- 菜单/角色绑定用 ON CONFLICT;字典用「先 DELETE 再 INSERT」收敛。 +-- 注意:本文件不 TRUNCATE sys_dict(避免清空其它字典及重置 id 影响 db_seed.sql)。 +BEGIN; + +-- ------------------------------------------------------------ +-- 1) worker 表扩展:电工 APP 登录与入驻审核 +-- ------------------------------------------------------------ +ALTER TABLE worker ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255); -- 电工APP登录密码bcrypt哈希 +ALTER TABLE worker ADD COLUMN IF NOT EXISTS audit_status SMALLINT NOT NULL DEFAULT 0; -- 审核状态:0待审核 1已通过 2已驳回 +ALTER TABLE worker ADD COLUMN IF NOT EXISTS audit_remark VARCHAR(255); -- 驳回理由/审核备注 +ALTER TABLE worker ADD COLUMN IF NOT EXISTS audit_by BIGINT; -- 审核人(→ sys_user.id) +ALTER TABLE worker ADD COLUMN IF NOT EXISTS audit_at TIMESTAMPTZ; -- 审核时间 +ALTER TABLE worker ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ; -- 最后登录时间 + +COMMENT ON COLUMN worker.password_hash IS '电工APP登录密码bcrypt哈希'; +COMMENT ON COLUMN worker.audit_status IS '审核状态:0待审核 1已通过 2已驳回'; +COMMENT ON COLUMN worker.audit_remark IS '驳回理由/审核备注'; +COMMENT ON COLUMN worker.audit_by IS '审核人(→ sys_user.id)'; +COMMENT ON COLUMN worker.audit_at IS '审核时间'; +COMMENT ON COLUMN worker.last_login_at IS '最后登录时间'; + +-- 电工登记台账按审核状态过滤/统计 +CREATE INDEX IF NOT EXISTS idx_worker_audit_status ON worker (audit_status); + +-- ------------------------------------------------------------ +-- 2) 电工资质证照表(电工登记管理的资质附件与到期提醒依据) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS worker_cert ( + id BIGSERIAL PRIMARY KEY, + worker_id BIGINT NOT NULL REFERENCES worker(id) ON DELETE CASCADE, + cert_type VARCHAR(64), -- 证书类型(字典 worker_cert_type) + cert_no VARCHAR(64), -- 证书编号 + issue_authority VARCHAR(128), -- 发证机关 + issue_date DATE, -- 发证日期 + expire_date DATE, -- 有效期至(到期提醒依据;NULL=长期有效) + cert_image_url VARCHAR(512), -- 证照图片对象key + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +COMMENT ON TABLE worker_cert IS '电工资质证照表'; +COMMENT ON COLUMN worker_cert.id IS '自增主键'; +COMMENT ON COLUMN worker_cert.worker_id IS '共享电工ID(→ worker)'; +COMMENT ON COLUMN worker_cert.cert_type IS '证书类型(字典 worker_cert_type)'; +COMMENT ON COLUMN worker_cert.cert_no IS '证书编号'; +COMMENT ON COLUMN worker_cert.issue_authority IS '发证机关'; +COMMENT ON COLUMN worker_cert.issue_date IS '发证日期'; +COMMENT ON COLUMN worker_cert.expire_date IS '有效期至(到期提醒依据;NULL=长期有效)'; +COMMENT ON COLUMN worker_cert.cert_image_url IS '证照图片对象key'; +COMMENT ON COLUMN worker_cert.created_at IS '创建时间'; +COMMENT ON COLUMN worker_cert.updated_at IS '更新时间'; + +CREATE INDEX IF NOT EXISTS idx_worker_cert_worker ON worker_cert (worker_id); +CREATE INDEX IF NOT EXISTS idx_worker_cert_expire ON worker_cert (expire_date); +CREATE UNIQUE INDEX IF NOT EXISTS uk_worker_cert_type_no ON worker_cert (worker_id, cert_type, cert_no); + +-- ------------------------------------------------------------ +-- 3) 客户运维服务价目表(运维费定价) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS service_price ( + id BIGSERIAL PRIMARY KEY, + price_code VARCHAR(64) NOT NULL UNIQUE, + price_name VARCHAR(64) NOT NULL, + service_type VARCHAR(32), + price_mode SMALLINT NOT NULL DEFAULT 1, + unit_price NUMERIC(10,2) NOT NULL DEFAULT 0, + unit VARCHAR(16), + status SMALLINT NOT NULL DEFAULT 1, + remark VARCHAR(255), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +COMMENT ON TABLE service_price IS '客户运维服务价目表'; +COMMENT ON COLUMN service_price.id IS '自增主键'; +COMMENT ON COLUMN service_price.price_code IS '价目编码(唯一,FW+YYYYMMDD+3位)'; +COMMENT ON COLUMN service_price.price_name IS '价目名称'; +COMMENT ON COLUMN service_price.service_type IS '服务类型:维修/维保/报警处理/巡检/其他'; +COMMENT ON COLUMN service_price.price_mode IS '计价方式:1按次 2按工时 3材料'; +COMMENT ON COLUMN service_price.unit_price IS '单价'; +COMMENT ON COLUMN service_price.unit IS '计价单位:次/小时/个/米/根'; +COMMENT ON COLUMN service_price.status IS '状态:1启用 0停用'; +COMMENT ON COLUMN service_price.remark IS '备注'; +COMMENT ON COLUMN service_price.created_at IS '创建时间'; +COMMENT ON COLUMN service_price.updated_at IS '更新时间'; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_service_price_code ON service_price (price_code); +CREATE INDEX IF NOT EXISTS idx_service_price_mode_status ON service_price (price_mode, status); + +-- ------------------------------------------------------------ +-- 4) dispatch_price 表扩展:电工派单成本价 +-- ------------------------------------------------------------ +ALTER TABLE dispatch_price ADD COLUMN IF NOT EXISTS price_mode SMALLINT NOT NULL DEFAULT 1; -- 计价方式:1按次 2按工时 +ALTER TABLE dispatch_price ADD COLUMN IF NOT EXISTS unit VARCHAR(16) DEFAULT '次'; -- 计价单位 +ALTER TABLE dispatch_price ADD COLUMN IF NOT EXISTS status SMALLINT NOT NULL DEFAULT 1; -- 状态:1启用 0停用 +ALTER TABLE dispatch_price ADD COLUMN IF NOT EXISTS remark VARCHAR(255); -- 备注 +ALTER TABLE dispatch_price ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +COMMENT ON COLUMN dispatch_price.price_mode IS '计价方式:1按次 2按工时'; +COMMENT ON COLUMN dispatch_price.unit IS '计价单位'; +COMMENT ON COLUMN dispatch_price.status IS '状态:1启用 0停用'; +COMMENT ON COLUMN dispatch_price.remark IS '备注'; +COMMENT ON COLUMN dispatch_price.updated_at IS '更新时间'; + +CREATE INDEX IF NOT EXISTS idx_dispatch_price_status ON dispatch_price (status); + +-- ------------------------------------------------------------ +-- 5) 站内通知表(当前用途:电工资质证照到期提醒) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS sys_notice ( + id BIGSERIAL PRIMARY KEY, + notice_type VARCHAR(32) NOT NULL, + title VARCHAR(128) NOT NULL, + content VARCHAR(512), + biz_type VARCHAR(32), + biz_id BIGINT, + receiver_id BIGINT NOT NULL, + is_read SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +COMMENT ON TABLE sys_notice IS '站内通知表'; +COMMENT ON COLUMN sys_notice.id IS '自增主键'; +COMMENT ON COLUMN sys_notice.notice_type IS '通知类型:cert_expire证照到期'; +COMMENT ON COLUMN sys_notice.title IS '通知标题'; +COMMENT ON COLUMN sys_notice.content IS '通知内容'; +COMMENT ON COLUMN sys_notice.biz_type IS '业务类型:worker_cert电工资质证照'; +COMMENT ON COLUMN sys_notice.biz_id IS '业务主键(→ worker_cert.id)'; +COMMENT ON COLUMN sys_notice.receiver_id IS '接收人(→ sys_user.id)'; +COMMENT ON COLUMN sys_notice.is_read IS '是否已读:0未读 1已读'; +COMMENT ON COLUMN sys_notice.created_at IS '创建时间'; + +CREATE INDEX IF NOT EXISTS idx_sys_notice_receiver ON sys_notice (receiver_id, is_read); +CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_notice_biz ON sys_notice (notice_type, biz_id, receiver_id); + +-- ------------------------------------------------------------ +-- 6) 菜单与权限 +-- 「运营派单」由顶级菜单项改为目录,运维费定价/成本价/电工登记作为其子菜单 +-- ------------------------------------------------------------ +UPDATE sys_menu SET menu_type = 1 WHERE id = 3; + +INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES +(34, 3, '运维费定价', '/dispatch/price', 'dispatch/price/index', 'MoneyCollectOutlined', 1, 2, 'dispatch:price', 1), +(35, 3, '电工派单成本价', '/dispatch/cost', 'dispatch/cost/index', 'AccountBookOutlined', 2, 2, 'dispatch:cost', 1), +(36, 3, '电工登记管理', '/dispatch/worker', 'dispatch/worker/index', 'ToolOutlined', 3, 2, 'dispatch:worker', 1) +ON CONFLICT (id) DO UPDATE SET + parent_id = EXCLUDED.parent_id, + menu_name = EXCLUDED.menu_name, + menu_path = EXCLUDED.menu_path, + component = EXCLUDED.component, + icon = EXCLUDED.icon, + sort_order = EXCLUDED.sort_order, + menu_type = EXCLUDED.menu_type, + permission = EXCLUDED.permission, + visible = EXCLUDED.visible; + +-- 按钮权限(menu_type=3,无路由/组件/图标) +INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES +(37, 3, '新增价目', NULL, NULL, NULL, 1, 3, 'price:create', 1), +(38, 3, '编辑价目', NULL, NULL, NULL, 2, 3, 'price:update', 1), +(39, 3, '删除价目', NULL, NULL, NULL, 3, 3, 'price:delete', 1), +(40, 3, '新增成本价', NULL, NULL, NULL, 4, 3, 'cost:create', 1), +(41, 3, '编辑成本价', NULL, NULL, NULL, 5, 3, 'cost:update', 1), +(42, 3, '删除成本价', NULL, NULL, NULL, 6, 3, 'cost:delete', 1), +(43, 3, '新增电工', NULL, NULL, NULL, 7, 3, 'worker:create', 1), +(44, 3, '编辑电工', NULL, NULL, NULL, 8, 3, 'worker:update', 1), +(45, 3, '删除电工', NULL, NULL, NULL, 9, 3, 'worker:delete', 1), +(46, 3, '审核电工', NULL, NULL, NULL, 10, 3, 'worker:audit', 1) +ON CONFLICT (id) DO UPDATE SET + parent_id = EXCLUDED.parent_id, + menu_name = EXCLUDED.menu_name, + menu_path = EXCLUDED.menu_path, + component = EXCLUDED.component, + icon = EXCLUDED.icon, + sort_order = EXCLUDED.sort_order, + menu_type = EXCLUDED.menu_type, + permission = EXCLUDED.permission, + visible = EXCLUDED.visible; + +-- 角色绑定:ADMIN(role_id=1)拥有 34~46 +INSERT INTO sys_role_menu (role_id, menu_id) VALUES +(1, 34), (1, 35), (1, 36), (1, 37), (1, 38), (1, 39), (1, 40), (1, 41), (1, 42), (1, 43), (1, 44), (1, 45), (1, 46) +ON CONFLICT (role_id, menu_id) DO NOTHING; + +-- 重置 sys_menu 自增序列,避免后续插入 id 冲突 +SELECT setval('sys_menu_id_seq', 46, true); + +-- ------------------------------------------------------------ +-- 7) 字典(sys_dict 无业务唯一键,先 DELETE 目标 dict_type 再 INSERT,保证幂等) +-- ------------------------------------------------------------ +DELETE FROM sys_dict WHERE dict_type IN ( + 'om_service_type', 'om_price_mode', 'om_price_unit', 'worker_cert_type', 'worker_audit_status' +); + +INSERT INTO sys_dict (dict_type, dict_label, dict_value, sort_order) VALUES +('om_service_type', '维修', '维修', 1), +('om_service_type', '维保', '维保', 2), +('om_service_type', '报警处理', '报警处理', 3), +('om_service_type', '巡检', '巡检', 4), +('om_service_type', '其他', '其他', 5), +('om_price_mode', '按次', '1', 1), +('om_price_mode', '按工时', '2', 2), +('om_price_mode', '材料', '3', 3), +('om_price_unit', '次', '次', 1), +('om_price_unit', '小时', '小时', 2), +('om_price_unit', '个', '个', 3), +('om_price_unit', '米', '米', 4), +('om_price_unit', '根', '根', 5), +('worker_cert_type', '低压电工证', '低压电工证', 1), +('worker_cert_type', '高压电工证', '高压电工证', 2), +('worker_cert_type', '特种作业证', '特种作业证', 3), +('worker_cert_type', '登高证', '登高证', 4), +('worker_cert_type', '其他', '其他', 5), +('worker_audit_status', '待审核', '0', 1), +('worker_audit_status', '已通过', '1', 2), +('worker_audit_status', '已驳回', '2', 3); + +COMMIT; \ No newline at end of file diff --git a/database/migrations/20261002_job_management.sql b/database/migrations/20261002_job_management.sql new file mode 100644 index 0000000..607a486 --- /dev/null +++ b/database/migrations/20261002_job_management.sql @@ -0,0 +1,123 @@ +-- 定时任务管理功能扩展 +-- 背景:系统周期性后台作业(物化视图刷新、日志清理等)分散在 pg_cron 与应用 @midwayjs/cron 注解中, +-- 运维无法在管理端查看/启停/调频/追溯执行记录。本期新增「自研轻量调度器 + 内置任务注册表」, +-- 新增 sys_job(任务定义与调度配置)、sys_job_log(执行日志)两张表, +-- 并挂载系统管理下的「定时任务」菜单与按钮权限。 +-- 幂等:建表/索引用 IF NOT EXISTS;菜单/角色绑定用 ON CONFLICT;任务种子用 ON CONFLICT (job_key) DO NOTHING。 +-- 注意:菜单 id 47-50,避开并行规格 add-om-pricing-and-worker-registry 已占用的 34-46。 +BEGIN; + +-- ------------------------------------------------------------ +-- 1) 任务定义与调度配置表 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS sys_job ( + id BIGSERIAL PRIMARY KEY, + job_key VARCHAR(64) NOT NULL UNIQUE, -- 任务键,与代码注册表一一对应 + job_name VARCHAR(64) NOT NULL, -- 任务名称 + cron_expression VARCHAR(64) NOT NULL, -- cron 表达式 + params JSONB NOT NULL DEFAULT '{}'::jsonb, -- 任务参数(受白名单约束) + retry_times SMALLINT NOT NULL DEFAULT 0, -- 失败后最大重试次数 0-5 + retry_interval INT NOT NULL DEFAULT 60, -- 失败后重试间隔(秒 10-3600) + status SMALLINT NOT NULL DEFAULT 1, -- 1启用 0停用 + remark VARCHAR(255), -- 备注 + last_run_at TIMESTAMPTZ, -- 上次执行时间 + last_run_status SMALLINT, -- 上次执行状态:1成功 2失败 3跳过 + last_run_cost_ms INT, -- 上次执行耗时(毫秒) + last_error TEXT, -- 上次失败原因(成功时清空) + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +COMMENT ON TABLE sys_job IS '定时任务定义与调度配置表'; +COMMENT ON COLUMN sys_job.id IS '自增主键'; +COMMENT ON COLUMN sys_job.job_key IS '任务键,与代码注册表一一对应'; +COMMENT ON COLUMN sys_job.job_name IS '任务名称'; +COMMENT ON COLUMN sys_job.cron_expression IS 'cron 表达式(分 时 日 月 周,支持 6 段带秒)'; +COMMENT ON COLUMN sys_job.params IS '任务参数(受白名单字段约束)'; +COMMENT ON COLUMN sys_job.retry_times IS '失败后最大重试次数 0-5'; +COMMENT ON COLUMN sys_job.retry_interval IS '失败后重试间隔(秒 10-3600)'; +COMMENT ON COLUMN sys_job.status IS '状态:1启用 0停用'; +COMMENT ON COLUMN sys_job.remark IS '备注'; +COMMENT ON COLUMN sys_job.last_run_at IS '上次执行时间'; +COMMENT ON COLUMN sys_job.last_run_status IS '上次执行状态:1成功 2失败 3跳过'; +COMMENT ON COLUMN sys_job.last_run_cost_ms IS '上次执行耗时(毫秒)'; +COMMENT ON COLUMN sys_job.last_error IS '上次失败原因(成功时清空)'; +COMMENT ON COLUMN sys_job.created_at IS '创建时间'; +COMMENT ON COLUMN sys_job.updated_at IS '更新时间'; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_job_key ON sys_job (job_key); +CREATE INDEX IF NOT EXISTS idx_sys_job_status ON sys_job (status); + +-- ------------------------------------------------------------ +-- 2) 任务执行日志表 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS sys_job_log ( + id BIGSERIAL PRIMARY KEY, + job_id BIGINT NOT NULL REFERENCES sys_job(id) ON DELETE CASCADE, -- 任务ID(→ sys_job.id) + job_key VARCHAR(64) NOT NULL, -- 任务键(冗余,便于查询) + job_name VARCHAR(64) NOT NULL, -- 任务名称(冗余) + trigger_type SMALLINT NOT NULL DEFAULT 1, -- 触发方式:1定时自动 2手动触发 3失败重试 + status SMALLINT NOT NULL, -- 1成功 2失败 3跳过 + attempt SMALLINT NOT NULL DEFAULT 1, -- 第几次尝试 + started_at TIMESTAMPTZ, -- 开始时间 + finished_at TIMESTAMPTZ, -- 结束时间 + cost_ms INT, -- 执行耗时(毫秒) + message TEXT, -- 结果摘要 + error_stack TEXT, -- 异常堆栈(截断 4000 字符) + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +COMMENT ON TABLE sys_job_log IS '定时任务执行日志表'; +COMMENT ON COLUMN sys_job_log.id IS '自增主键'; +COMMENT ON COLUMN sys_job_log.job_id IS '任务ID(→ sys_job.id)'; +COMMENT ON COLUMN sys_job_log.job_key IS '任务键(冗余,便于查询)'; +COMMENT ON COLUMN sys_job_log.job_name IS '任务名称(冗余)'; +COMMENT ON COLUMN sys_job_log.trigger_type IS '触发方式:1定时自动 2手动触发 3失败重试'; +COMMENT ON COLUMN sys_job_log.status IS '执行状态:1成功 2失败 3跳过'; +COMMENT ON COLUMN sys_job_log.attempt IS '第几次尝试'; +COMMENT ON COLUMN sys_job_log.started_at IS '开始时间'; +COMMENT ON COLUMN sys_job_log.finished_at IS '结束时间'; +COMMENT ON COLUMN sys_job_log.cost_ms IS '执行耗时(毫秒)'; +COMMENT ON COLUMN sys_job_log.message IS '结果摘要'; +COMMENT ON COLUMN sys_job_log.error_stack IS '异常堆栈(截断 4000 字符)'; +COMMENT ON COLUMN sys_job_log.created_at IS '创建时间'; + +CREATE INDEX IF NOT EXISTS idx_sys_job_log_job_created ON sys_job_log (job_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_sys_job_log_created ON sys_job_log (created_at); + +-- ------------------------------------------------------------ +-- 3) 菜单与权限(系统管理 parent_id=6 下新增「定时任务」菜单 + 3 个按钮权限) +-- ------------------------------------------------------------ +INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES +(47, 6, '定时任务', '/system/job', 'system/job/index', 'ClockCircleOutlined', 6, 2, 'system:job', 1), +(48, 47, '编辑定时任务', NULL, NULL, NULL, 1, 3, 'system:job:update', 1), +(49, 47, '立即执行', NULL, NULL, NULL, 2, 3, 'system:job:run', 1), +(50, 47, '清理日志', NULL, NULL, NULL, 3, 3, 'system:job:log:clear', 1) +ON CONFLICT (id) DO UPDATE SET + parent_id = EXCLUDED.parent_id, + menu_name = EXCLUDED.menu_name, + menu_path = EXCLUDED.menu_path, + component = EXCLUDED.component, + icon = EXCLUDED.icon, + sort_order = EXCLUDED.sort_order, + menu_type = EXCLUDED.menu_type, + permission = EXCLUDED.permission, + visible = EXCLUDED.visible; + +-- 角色绑定:ADMIN(role_id=1)拥有 47~50 +INSERT INTO sys_role_menu (role_id, menu_id) VALUES +(1, 47), (1, 48), (1, 49), (1, 50) +ON CONFLICT (role_id, menu_id) DO NOTHING; + +-- 重置 sys_menu 自增序列,避免后续插入 id 冲突 +SELECT setval('sys_menu_id_seq', 50, true); + +-- ------------------------------------------------------------ +-- 4) sys_job 内置任务种子(按 job_key 幂等) +-- ------------------------------------------------------------ +INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES +('demo_heartbeat', '示例心跳', '*/5 * * * *', '{"message":"heartbeat"}'::jsonb, 0, 60, 1, + '示例心跳任务:用于验证调度链路,默认每 5 分钟写入一条 info 日志'), +('log_clean', '日志清理', '0 3 * * *', '{"keepDays":90}'::jsonb, 2, 60, 1, + '日志清理任务:每日 03:00 清理早于 keepDays 天的操作日志与任务日志,默认保留 90 天') +ON CONFLICT (job_key) DO NOTHING; + +COMMIT; diff --git a/server/package.json b/server/package.json index 77e8204..14290c3 100644 --- a/server/package.json +++ b/server/package.json @@ -17,6 +17,7 @@ "@aws-sdk/s3-request-presigner": "^3.1131.0", "@midwayjs/cache-manager": "^3.19.0", "@midwayjs/core": "^3.19.0", + "@midwayjs/cron": "^3.19.0", "@midwayjs/koa": "^3.19.0", "@midwayjs/logger": "^4.0.1", "@midwayjs/sequelize": "^3.19.0", @@ -27,6 +28,7 @@ "cache-manager-ioredis": "^2.1.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "cron": "^3.5.0", "dayjs": "^1.11.13", "dotenv": "^17.4.2", "ioredis": "^5.4.1", diff --git a/server/src/configuration.ts b/server/src/configuration.ts index 6d5153b..e52b0a9 100644 --- a/server/src/configuration.ts +++ b/server/src/configuration.ts @@ -2,14 +2,17 @@ import { Configuration, App, ILifeCycle } from "@midwayjs/core"; import * as Koa from "@midwayjs/koa"; import * as Sequelize from "@midwayjs/sequelize"; import * as CacheManager from "@midwayjs/cache-manager"; +import * as Cron from "@midwayjs/cron"; import * as Upload from "@midwayjs/upload"; import { join } from "path"; import { AuthMiddleware } from "./middleware/auth.middleware"; +import { WorkerAuthMiddleware } from "./middleware/worker_auth.middleware"; import { RbacMiddleware } from "./middleware/rbac.middleware"; import { LogMiddleware } from "./middleware/log.middleware"; +import { JobSchedulerService } from "./service/job_scheduler.service"; @Configuration({ - imports: [Koa, Sequelize, CacheManager, Upload], + imports: [Koa, Sequelize, CacheManager, Upload, Cron], importConfigs: [join(__dirname, "./config")] }) export class ContainerConfigLifeCycle implements ILifeCycle { @@ -17,12 +20,24 @@ export class ContainerConfigLifeCycle implements ILifeCycle { app!: Koa.Application; async onReady() { - // 注册全局中间件(顺序:鉴权 → 权限 → 日志) - this.app.useMiddleware([AuthMiddleware, RbacMiddleware, LogMiddleware]); + // 注册全局中间件(顺序:电工鉴权 → 鉴权 → 权限 → 日志) + this.app.useMiddleware([WorkerAuthMiddleware, AuthMiddleware, RbacMiddleware, LogMiddleware]); + try { + const scheduler = await this.app.getApplicationContext().getAsync(JobSchedulerService); + await scheduler.start(); + } catch (err) { + console.error("[定时任务] 调度器启动失败:", err); + } console.log("智能物联网设备后台管理系统启动成功"); } async onStop() { + try { + const scheduler = await this.app.getApplicationContext().getAsync(JobSchedulerService); + await scheduler.stop(); + } catch (err) { + console.error("[定时任务] 调度器停止失败:", err); + } console.log("系统已停止"); } } diff --git a/server/src/controller/dispatch_price.controller.ts b/server/src/controller/dispatch_price.controller.ts new file mode 100644 index 0000000..0de80f0 --- /dev/null +++ b/server/src/controller/dispatch_price.controller.ts @@ -0,0 +1,42 @@ +import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core"; +import { Context } from "@midwayjs/koa"; +import { DispatchPriceService } from "../service/dispatch_price.service"; + +@Controller("/api/dispatch-prices") +export class DispatchPriceController { + @Inject() + ctx!: Context; + + @Inject() + dispatchPriceService!: DispatchPriceService; + + @Get("/") + async list(@Query() query: any) { + return this.dispatchPriceService.list(query, this.ctx); + } + + @Get("/:id") + async detail(@Param("id") id: string) { + return this.dispatchPriceService.detail(Number(id)); + } + + @Post("/") + async create(@Body() body: any) { + return this.dispatchPriceService.create(body, this.ctx); + } + + @Put("/:id") + async update(@Param("id") id: string, @Body() body: any) { + return this.dispatchPriceService.update(Number(id), body, this.ctx); + } + + @Del("/:id") + async delete(@Param("id") id: string) { + return this.dispatchPriceService.delete(Number(id), this.ctx); + } + + @Put("/:id/status") + async updateStatus(@Param("id") id: string, @Body() body: { status: number }) { + return this.dispatchPriceService.updateStatus(Number(id), body.status, this.ctx); + } +} \ No newline at end of file diff --git a/server/src/controller/job.controller.ts b/server/src/controller/job.controller.ts new file mode 100644 index 0000000..93a84fb --- /dev/null +++ b/server/src/controller/job.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core"; +import { JobService } from "../service/job.service"; +import { UpdateJobDTO, JobStatusDTO, JobLogQueryDTO } from "../dto/job.dto"; + +@Controller("/api/jobs") +export class JobController { + @Inject() + jobService!: JobService; + + @Get("/") + async list(@Query() query: { page?: number; pageSize?: number; jobName?: string; status?: number }) { + return this.jobService.list(query); + } + + // 静态路径必须声明在动态路径之前 + @Del("/logs/clear") + async clearLogs(@Query("days") days?: string) { + const parsed = days ? Number(days) : 90; + return this.jobService.clearLogs(parsed); + } + + @Put("/:id") + async update(@Param("id") id: string, @Body() dto: UpdateJobDTO) { + return this.jobService.update(Number(id), dto); + } + + @Put("/:id/status") + async changeStatus(@Param("id") id: string, @Body() dto: JobStatusDTO) { + return this.jobService.changeStatus(Number(id), dto.status); + } + + @Post("/:id/run") + async run(@Param("id") id: string) { + return this.jobService.run(Number(id)); + } + + @Get("/:id/logs") + async listLogs(@Param("id") id: string, @Query() query: JobLogQueryDTO) { + return this.jobService.listLogs(Number(id), query); + } +} diff --git a/server/src/controller/notice.controller.ts b/server/src/controller/notice.controller.ts new file mode 100644 index 0000000..519a80f --- /dev/null +++ b/server/src/controller/notice.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Get, Put, Inject, Query, Param } from "@midwayjs/core"; +import { Context } from "@midwayjs/koa"; +import { NoticeService } from "../service/notice.service"; + +@Controller("/api/notices") +export class NoticeController { + @Inject() + ctx!: Context; + + @Inject() + noticeService!: NoticeService; + + @Get("/") + async list(@Query() query: any) { + return await this.noticeService.listMy(this.currentUserId(), query); + } + + // 注意:静态路径必须注册在动态路径 /:id/read 之前 + @Get("/unread-count") + async unreadCount() { + return await this.noticeService.unreadCount(this.currentUserId()); + } + + @Put("/read-all") + async markAllRead() { + return await this.noticeService.markAllRead(this.currentUserId()); + } + + @Put("/:id/read") + async markRead(@Param("id") id: string) { + return await this.noticeService.markRead(this.currentUserId(), Number(id)); + } + + private currentUserId(): number { + return Number(this.ctx.state.user?.userId); + } +} \ No newline at end of file diff --git a/server/src/controller/service_price.controller.ts b/server/src/controller/service_price.controller.ts new file mode 100644 index 0000000..0c18380 --- /dev/null +++ b/server/src/controller/service_price.controller.ts @@ -0,0 +1,42 @@ +import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core"; +import { Context } from "@midwayjs/koa"; +import { ServicePriceService } from "../service/service_price.service"; + +@Controller("/api/service-prices") +export class ServicePriceController { + @Inject() + ctx!: Context; + + @Inject() + servicePriceService!: ServicePriceService; + + @Get("/") + async list(@Query() query: any) { + return this.servicePriceService.list(query, this.ctx); + } + + @Get("/:id") + async detail(@Param("id") id: string) { + return this.servicePriceService.detail(Number(id)); + } + + @Post("/") + async create(@Body() body: any) { + return this.servicePriceService.create(body, this.ctx); + } + + @Put("/:id") + async update(@Param("id") id: string, @Body() body: any) { + return this.servicePriceService.update(Number(id), body, this.ctx); + } + + @Del("/:id") + async delete(@Param("id") id: string) { + return this.servicePriceService.delete(Number(id), this.ctx); + } + + @Put("/:id/status") + async updateStatus(@Param("id") id: string, @Body() body: { status: number }) { + return this.servicePriceService.updateStatus(Number(id), body.status, this.ctx); + } +} \ No newline at end of file diff --git a/server/src/controller/worker.controller.ts b/server/src/controller/worker.controller.ts index a4ad8ec..753c666 100644 --- a/server/src/controller/worker.controller.ts +++ b/server/src/controller/worker.controller.ts @@ -1,6 +1,7 @@ -import { Controller, Get, Inject, Query } from "@midwayjs/core"; +import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core"; import { Context } from "@midwayjs/koa"; import { WorkerService } from "../service/worker.service"; +import { WorkerCreateDTO, WorkerUpdateDTO, WorkerAuditDTO, WorkerCertDTO } from "../dto/worker.dto"; @Controller("/api/workers") export class WorkerController { @@ -10,6 +11,34 @@ export class WorkerController { @Get("/") async list(@Query() query: any) { return this.workerService.list(query, this.ctx); } // 静态路径必须声明在动态路径之前 + @Get("/stats") async stats() { return this.workerService.stats(); } @Get("/options") async options() { return this.workerService.options(this.ctx); } @Get("/recommend") async recommend(@Query() query: any) { return this.workerService.recommend(query, this.ctx); } -} + + @Get("/:id") async detail(@Param("id") id: string) { return this.workerService.detail(Number(id)); } + + @Post("/") async create(@Body() body: WorkerCreateDTO) { return this.workerService.create(body); } + @Put("/:id") async update(@Param("id") id: string, @Body() body: WorkerUpdateDTO) { + return this.workerService.update(Number(id), body); + } + @Del("/:id") async remove(@Param("id") id: string) { return this.workerService.remove(Number(id)); } + + // 资质审核:操作人取自登录态 + @Put("/:id/audit") async audit(@Param("id") id: string, @Body() body: WorkerAuditDTO) { + return this.workerService.audit(Number(id), body, this.ctx.state.user?.userId); + } + + @Post("/:id/certs") async addCert(@Param("id") id: string, @Body() body: WorkerCertDTO) { + return this.workerService.saveCert(Number(id), body); + } + @Put("/:id/certs/:certId") async updateCert( + @Param("id") id: string, + @Param("certId") certId: string, + @Body() body: WorkerCertDTO + ) { + return this.workerService.saveCert(Number(id), body, Number(certId)); + } + @Del("/:id/certs/:certId") async removeCert(@Param("id") id: string, @Param("certId") certId: string) { + return this.workerService.removeCert(Number(id), Number(certId)); + } +} \ No newline at end of file diff --git a/server/src/controller/worker_app.controller.ts b/server/src/controller/worker_app.controller.ts new file mode 100644 index 0000000..e47712b --- /dev/null +++ b/server/src/controller/worker_app.controller.ts @@ -0,0 +1,79 @@ +import { Controller, Get, Post, Put, Del, Inject, Body, Param } from "@midwayjs/core"; +import { Context } from "@midwayjs/koa"; +import { WorkerAppService } from "../service/worker_app.service"; +import { + WorkerRegisterDTO, + WorkerLoginDTO, + WorkerProfileUpdateDTO, + WorkerAppCertDTO +} from "../dto/worker_app.dto"; + +@Controller("/api/worker-app") +export class WorkerAppController { + @Inject() + ctx!: Context; + + @Inject() + workerAppService!: WorkerAppService; + + // 注册(匿名) + @Post("/auth/register") + async register(@Body() body: WorkerRegisterDTO) { + return this.workerAppService.register(body); + } + + // 登录(匿名) + @Post("/auth/login") + async login(@Body() body: WorkerLoginDTO) { + return this.workerAppService.login(body); + } + + // 个人资料 + @Get("/profile") + async profile() { + return this.workerAppService.profile(this.currentWorkerId()); + } + + @Put("/profile") + async updateProfile(@Body() body: WorkerProfileUpdateDTO) { + return this.workerAppService.updateProfile(this.currentWorkerId(), body); + } + + // 资质证照:静态路径必须声明在动态路径之前 + @Get("/certs") + async listCerts() { + return this.workerAppService.listCerts(this.currentWorkerId()); + } + + @Post("/certs") + async createCert(@Body() body: WorkerAppCertDTO) { + return this.workerAppService.createCert(this.currentWorkerId(), body); + } + + @Put("/certs/:id") + async updateCert(@Param("id") id: string, @Body() body: WorkerAppCertDTO) { + return this.workerAppService.updateCert(this.currentWorkerId(), Number(id), body); + } + + @Del("/certs/:id") + async removeCert(@Param("id") id: string) { + return this.workerAppService.removeCert(this.currentWorkerId(), Number(id)); + } + + // 重新提交审核 + @Post("/audit/resubmit") + async resubmit() { + return this.workerAppService.resubmit(this.currentWorkerId()); + } + + // 证照到期提醒 + @Get("/notices") + async listNotices() { + return this.workerAppService.listNotices(this.currentWorkerId()); + } + + // 当前登录电工 ID(由 WorkerAuthMiddleware 写入) + private currentWorkerId(): number { + return this.ctx.state.worker?.workerId; + } +} \ No newline at end of file diff --git a/server/src/dto/dispatch_price.dto.ts b/server/src/dto/dispatch_price.dto.ts new file mode 100644 index 0000000..4c94a63 --- /dev/null +++ b/server/src/dto/dispatch_price.dto.ts @@ -0,0 +1,52 @@ +import { IsNotEmpty, IsOptional, IsString, IsInt, IsNumber, IsIn, Min, Max, MaxLength } from "class-validator"; + +export class DispatchPriceQueryDTO { + @IsOptional() @IsInt() @Min(1) page?: number = 1; + + @IsOptional() @IsInt() @Min(1) @Max(200, { message: "分页大小不能超过 200 条" }) pageSize?: number = 10; + + @IsOptional() @IsString() @MaxLength(64, { message: "关键词最大 64 字符" }) keyword?: string; + + @IsOptional() @IsString() @MaxLength(32) dispatchType?: string; + + @IsOptional() @IsIn([1, 2], { message: "计价方式非法" }) priceMode?: number; + + @IsOptional() @IsIn([0, 1], { message: "状态值非法" }) status?: number; +} + +export class DispatchPriceCreateDTO { + @IsNotEmpty({ message: "派单类型不能为空" }) + @IsString() + @MaxLength(32) + dispatchType!: string; + + @IsNotEmpty({ message: "单价不能为空" }) + @IsNumber({}, { message: "单价必须为数字" }) + unitPrice!: number; + + @IsOptional() @IsString() @MaxLength(32) priceType?: string; + + @IsOptional() @IsIn([1, 2], { message: "计价方式非法" }) priceMode?: number; + + @IsOptional() @IsString() @MaxLength(16) unit?: string; + + @IsOptional() @IsIn([0, 1], { message: "状态值非法" }) status?: number; + + @IsOptional() @IsString() @MaxLength(255) remark?: string; +} + +export class DispatchPriceUpdateDTO { + @IsOptional() @IsString() @MaxLength(32) dispatchType?: string; + + @IsOptional() @IsString() @MaxLength(32) priceType?: string; + + @IsOptional() @IsIn([1, 2], { message: "计价方式非法" }) priceMode?: number; + + @IsOptional() @IsNumber({}, { message: "单价必须为数字" }) unitPrice?: number; + + @IsOptional() @IsString() @MaxLength(16) unit?: string; + + @IsOptional() @IsIn([0, 1], { message: "状态值非法" }) status?: number; + + @IsOptional() @IsString() @MaxLength(255) remark?: string; +} \ No newline at end of file diff --git a/server/src/dto/job.dto.ts b/server/src/dto/job.dto.ts new file mode 100644 index 0000000..3a32719 --- /dev/null +++ b/server/src/dto/job.dto.ts @@ -0,0 +1,63 @@ +import { IsNotEmpty, IsOptional, IsString, IsInt, IsObject, Min, Max, MaxLength } from "class-validator"; + +export class UpdateJobDTO { + @IsString() + @IsNotEmpty({ message: "cron 表达式不能为空" }) + @MaxLength(64, { message: "cron 表达式最大 64 字符" }) + cronExpression!: string; + + @IsOptional() + @IsObject() + params?: Record; + + @IsInt() + @Min(0, { message: "重试次数不能小于 0" }) + @Max(5, { message: "重试次数不能超过 5" }) + retryTimes!: number; + + @IsInt() + @Min(10, { message: "重试间隔不能小于 10 秒" }) + @Max(3600, { message: "重试间隔不能超过 3600 秒" }) + retryInterval!: number; + + @IsOptional() + @IsString() + @MaxLength(255, { message: "备注最大 255 字符" }) + remark?: string; +} + +export class JobStatusDTO { + @IsInt() + @Min(0, { message: "状态值非法" }) + @Max(1, { message: "状态值非法" }) + status!: number; +} + +export class JobLogQueryDTO { + @IsOptional() + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @IsInt() + @Min(1) + @Max(200, { message: "分页大小不能超过 200 条" }) + pageSize?: number = 10; + + @IsOptional() + @IsInt() + status?: number; + + @IsOptional() + @IsInt() + triggerType?: number; + + @IsOptional() + @IsString() + startDate?: string; + + @IsOptional() + @IsString() + endDate?: string; +} diff --git a/server/src/dto/service_price.dto.ts b/server/src/dto/service_price.dto.ts new file mode 100644 index 0000000..6d46a84 --- /dev/null +++ b/server/src/dto/service_price.dto.ts @@ -0,0 +1,52 @@ +import { IsNotEmpty, IsOptional, IsString, IsInt, IsNumber, IsIn, Min, Max, MaxLength } from "class-validator"; + +export class ServicePriceQueryDTO { + @IsOptional() @IsInt() @Min(1) page?: number = 1; + + @IsOptional() @IsInt() @Min(1) @Max(200, { message: "分页大小不能超过 200 条" }) pageSize?: number = 10; + + @IsOptional() @IsString() @MaxLength(64, { message: "关键词最大 64 字符" }) keyword?: string; + + @IsOptional() @IsIn([1, 2, 3], { message: "计价方式非法" }) priceMode?: number; + + @IsOptional() @IsString() @MaxLength(32) serviceType?: string; + + @IsOptional() @IsIn([0, 1], { message: "状态值非法" }) status?: number; +} + +export class ServicePriceCreateDTO { + @IsNotEmpty({ message: "价目名称不能为空" }) + @IsString() + @MaxLength(64) + priceName!: string; + + @IsNotEmpty({ message: "单价不能为空" }) + @IsNumber({}, { message: "单价必须为数字" }) + unitPrice!: number; + + @IsOptional() @IsString() @MaxLength(32) serviceType?: string; + + @IsOptional() @IsIn([1, 2, 3], { message: "计价方式非法" }) priceMode?: number; + + @IsOptional() @IsString() @MaxLength(16) unit?: string; + + @IsOptional() @IsIn([0, 1], { message: "状态值非法" }) status?: number; + + @IsOptional() @IsString() @MaxLength(255) remark?: string; +} + +export class ServicePriceUpdateDTO { + @IsOptional() @IsString() @MaxLength(64) priceName?: string; + + @IsOptional() @IsString() @MaxLength(32) serviceType?: string; + + @IsOptional() @IsIn([1, 2, 3], { message: "计价方式非法" }) priceMode?: number; + + @IsOptional() @IsNumber({}, { message: "单价必须为数字" }) unitPrice?: number; + + @IsOptional() @IsString() @MaxLength(16) unit?: string; + + @IsOptional() @IsIn([0, 1], { message: "状态值非法" }) status?: number; + + @IsOptional() @IsString() @MaxLength(255) remark?: string; +} \ No newline at end of file diff --git a/server/src/dto/worker.dto.ts b/server/src/dto/worker.dto.ts index b71ab9e..fd9298b 100644 --- a/server/src/dto/worker.dto.ts +++ b/server/src/dto/worker.dto.ts @@ -1,8 +1,27 @@ -import { IsOptional, IsString, IsInt, Min, Max, MaxLength } from "class-validator"; +import { + IsOptional, + IsString, + IsInt, + IsNumber, + IsBoolean, + IsArray, + IsIn, + IsNotEmpty, + ValidateNested, + Matches, + Min, + Max, + MaxLength +} from "class-validator"; +import { Type } from "class-transformer"; export class WorkerQueryDTO { @IsOptional() @IsString() @MaxLength(64) keyword?: string; @IsOptional() @IsInt() status?: number; + // 审核状态筛选:0待审核 1已通过 2已驳回 + @IsOptional() @IsInt() @IsIn([0, 1, 2]) auditStatus?: number; + // 资质状态筛选:none无证书 normal正常 expiring临期 expired已过期 + @IsOptional() @IsString() @IsIn(["none", "normal", "expiring", "expired"]) certStatus?: string; @IsOptional() @IsInt() @Min(1) page?: number = 1; @IsOptional() @IsInt() @Min(1) @Max(200) pageSize?: number = 10; // 兼容部分前端分页参数命名 @@ -14,3 +33,52 @@ export class WorkerRecommendQueryDTO { @IsOptional() @IsInt() @Min(1) @Max(3) priority?: number; @IsOptional() @IsString() @MaxLength(64) certKeyword?: string; } + +// 单条资质证照 +export class WorkerCertDTO { + @IsNotEmpty({ message: "证书类型不能为空" }) @IsString() @MaxLength(64) certType!: string; + @IsNotEmpty({ message: "证书编号不能为空" }) @IsString() @MaxLength(64) certNo!: string; + @IsOptional() @IsString() @MaxLength(128) issueAuthority?: string; + @IsOptional() @IsString() @MaxLength(10) issueDate?: string; + // 有效期至(NULL/缺省=长期有效) + @IsOptional() @IsString() @MaxLength(10) expireDate?: string; + @IsOptional() @IsString() @MaxLength(512) certImageUrl?: string; +} + +// 证书整体保存(覆盖式) +export class WorkerCertListDTO { + @IsArray() @ValidateNested({ each: true }) @Type(() => WorkerCertDTO) certs!: WorkerCertDTO[]; +} + +// 电工新增 +export class WorkerCreateDTO { + @IsNotEmpty({ message: "电工姓名不能为空" }) @IsString() @MaxLength(64) workerName!: string; + @IsNotEmpty({ message: "手机号不能为空" }) @Matches(/^1[3-9]\d{9}$/, { message: "手机号格式不正确" }) phone!: string; + @IsOptional() @IsString() @MaxLength(18) idCard?: string; + @IsOptional() @IsString() @MaxLength(32) workerTitle?: string; + @IsOptional() @IsNumber() @Min(0) @Max(5) rating?: number; + @IsOptional() @IsString() @MaxLength(128) certInfo?: string; + // 是否同时开通电工APP账号(默认密码为手机号后 6 位) + @IsOptional() @IsBoolean() withAccount?: boolean = false; + @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => WorkerCertDTO) certs?: WorkerCertDTO[]; +} + +// 电工更新(字段白名单) +export class WorkerUpdateDTO { + @IsOptional() @IsString() @MaxLength(64) workerName?: string; + @IsOptional() @Matches(/^1[3-9]\d{9}$/, { message: "手机号格式不正确" }) phone?: string; + @IsOptional() @IsString() @MaxLength(18) idCard?: string; + @IsOptional() @IsString() @MaxLength(32) workerTitle?: string; + @IsOptional() @IsInt() @IsIn([0, 1]) status?: number; + @IsOptional() @IsNumber() @Min(0) @Max(5) rating?: number; + @IsOptional() @IsString() @MaxLength(128) certInfo?: string; + // 存在时整体覆盖该电工的全部证书 + @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => WorkerCertDTO) certs?: WorkerCertDTO[]; +} + +// 资质审核 +export class WorkerAuditDTO { + @IsNotEmpty({ message: "审核结果不能为空" }) @IsBoolean() approved!: boolean; + // 驳回理由(驳回时必填) + @IsOptional() @IsString() @MaxLength(255) remark?: string; +} \ No newline at end of file diff --git a/server/src/dto/worker_app.dto.ts b/server/src/dto/worker_app.dto.ts new file mode 100644 index 0000000..6bf9043 --- /dev/null +++ b/server/src/dto/worker_app.dto.ts @@ -0,0 +1,95 @@ +import { IsNotEmpty, IsOptional, IsString, IsDateString, MinLength, MaxLength, Matches } from "class-validator"; + +// 电工 APP 注册 +export class WorkerRegisterDTO { + @IsNotEmpty({ message: "电工姓名不能为空" }) + @IsString() + @MaxLength(64, { message: "电工姓名最大 64 字符" }) + workerName!: string; + + @IsNotEmpty({ message: "手机号不能为空" }) + @Matches(/^1[3-9]\d{9}$/, { message: "手机号格式不正确" }) + phone!: string; + + @IsNotEmpty({ message: "密码不能为空" }) + @IsString() + @MinLength(6, { message: "密码长度不能小于6个字符" }) + @MaxLength(64, { message: "密码长度不能超过64个字符" }) + password!: string; + + @IsOptional() + @IsString() + @MaxLength(18, { message: "身份证号最大 18 字符" }) + idCard?: string; + + @IsOptional() + @IsString() + @MaxLength(32, { message: "职衔最大 32 字符" }) + workerTitle?: string; +} + +// 电工 APP 登录 +export class WorkerLoginDTO { + @IsNotEmpty({ message: "手机号不能为空" }) + @Matches(/^1[3-9]\d{9}$/, { message: "手机号格式不正确" }) + phone!: string; + + @IsNotEmpty({ message: "密码不能为空" }) + @IsString() + password!: string; +} + +// 电工 APP 个人资料更新 +export class WorkerProfileUpdateDTO { + @IsOptional() + @IsString() + @MaxLength(64, { message: "电工姓名最大 64 字符" }) + workerName?: string; + + @IsOptional() + @IsString() + @MaxLength(18, { message: "身份证号最大 18 字符" }) + idCard?: string; + + @IsOptional() + @IsString() + @MaxLength(32, { message: "职衔最大 32 字符" }) + workerTitle?: string; + + @IsOptional() + @IsString() + @MinLength(6, { message: "密码长度不能小于6个字符" }) + @MaxLength(64, { message: "密码长度不能超过64个字符" }) + password?: string; +} + +// 电工资质证照新增/修改 +export class WorkerAppCertDTO { + @IsNotEmpty({ message: "证书类型不能为空" }) + @IsString() + @MaxLength(64, { message: "证书类型最大 64 字符" }) + certType!: string; + + @IsOptional() + @IsString() + @MaxLength(64, { message: "证书编号最大 64 字符" }) + certNo?: string; + + @IsOptional() + @IsString() + @MaxLength(128, { message: "发证机关最大 128 字符" }) + issueAuthority?: string; + + @IsOptional() + @IsDateString({}, { message: "发证日期格式不正确" }) + issueDate?: string; + + @IsOptional() + @IsDateString({}, { message: "有效期格式不正确" }) + expireDate?: string; + + @IsOptional() + @IsString() + @MaxLength(512, { message: "证照图片地址最大 512 字符" }) + certImageUrl?: string; +} \ No newline at end of file diff --git a/server/src/entity/dispatch_price.entity.ts b/server/src/entity/dispatch_price.entity.ts index f137104..61e4bc5 100644 --- a/server/src/entity/dispatch_price.entity.ts +++ b/server/src/entity/dispatch_price.entity.ts @@ -1,11 +1,12 @@ -import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript"; +import { Column, Model, Table, DataType, CreatedAt, UpdatedAt } from "sequelize-typescript"; @Table({ tableName: "dispatch_price", comment: "共享电工派单价格信息表", timestamps: true, createdAt: "created_at", - updatedAt: false + updatedAt: "updated_at", + indexes: [{ name: "idx_dispatch_price_status", fields: ["status"] }] }) export default class DispatchPrice extends Model { @Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true }) @@ -23,6 +24,21 @@ export default class DispatchPrice extends Model { @Column({ type: DataType.DECIMAL(10, 2), field: "unit_price", comment: "服务单价" }) unitPrice!: number; + @Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, field: "price_mode", comment: "计价方式:1按次 2按工时" }) + priceMode!: number; + + @Column({ type: DataType.STRING(16), defaultValue: "次", field: "unit", comment: "计价单位" }) + unit!: string; + + @Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, field: "status", comment: "状态:1启用 0停用" }) + status!: number; + + @Column({ type: DataType.STRING(255), field: "remark", comment: "备注" }) + remark!: string; + @CreatedAt created_at!: Date; -} + + @UpdatedAt + updated_at!: Date; +} \ No newline at end of file diff --git a/server/src/entity/service_price.entity.ts b/server/src/entity/service_price.entity.ts new file mode 100644 index 0000000..a3fa807 --- /dev/null +++ b/server/src/entity/service_price.entity.ts @@ -0,0 +1,47 @@ +import { Column, Model, Table, DataType, CreatedAt, UpdatedAt } from "sequelize-typescript"; + +@Table({ + tableName: "service_price", + comment: "客户运维服务价目表", + timestamps: true, + createdAt: "created_at", + updatedAt: "updated_at", + indexes: [ + { name: "uk_service_price_code", unique: true, fields: ["price_code"] }, + { name: "idx_service_price_mode_status", fields: ["price_mode", "status"] } + ] +}) +export default class ServicePrice extends Model { + @Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true }) + id!: number; + + @Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "price_code", comment: "价目编码(唯一,FW+YYYYMMDD+3位)" }) + priceCode!: string; + + @Column({ type: DataType.STRING(64), allowNull: false, field: "price_name", comment: "价目名称" }) + priceName!: string; + + @Column({ type: DataType.STRING(32), field: "service_type", comment: "服务类型:维修/维保/报警处理/巡检/其他" }) + serviceType!: string; + + @Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, field: "price_mode", comment: "计价方式:1按次 2按工时 3材料" }) + priceMode!: number; + + @Column({ type: DataType.DECIMAL(10, 2), allowNull: false, defaultValue: 0, field: "unit_price", comment: "单价" }) + unitPrice!: number; + + @Column({ type: DataType.STRING(16), field: "unit", comment: "计价单位:次/小时/个/米/根" }) + unit!: string; + + @Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, field: "status", comment: "状态:1启用 0停用" }) + status!: number; + + @Column({ type: DataType.STRING(255), field: "remark", comment: "备注" }) + remark!: string; + + @CreatedAt + created_at!: Date; + + @UpdatedAt + updated_at!: Date; +} \ No newline at end of file diff --git a/server/src/entity/sys_job.entity.ts b/server/src/entity/sys_job.entity.ts new file mode 100644 index 0000000..baae1fa --- /dev/null +++ b/server/src/entity/sys_job.entity.ts @@ -0,0 +1,105 @@ +import { Column, Model, Table, DataType, CreatedAt, UpdatedAt } from "sequelize-typescript"; + +@Table({ + tableName: "sys_job", + comment: "定时任务定义与调度配置表", + timestamps: true, + createdAt: "created_at", + updatedAt: "updated_at" +}) +export default class SysJob extends Model { + @Column({ + type: DataType.BIGINT, + autoIncrement: true, + primaryKey: true + }) + id!: number; + + @Column({ + type: DataType.STRING(64), + field: "job_key", + comment: "任务键(唯一标识)" + }) + jobKey!: string; + + @Column({ + type: DataType.STRING(64), + field: "job_name", + comment: "任务名称" + }) + jobName!: string; + + @Column({ + type: DataType.STRING(64), + field: "cron_expression", + comment: "cron 表达式" + }) + cronExpression!: string; + + @Column({ + type: DataType.JSONB, + field: "params", + comment: "任务参数(JSONB)" + }) + params!: Record; + + @Column({ + type: DataType.SMALLINT, + field: "retry_times", + comment: "重试次数" + }) + retryTimes!: number; + + @Column({ + type: DataType.INTEGER, + field: "retry_interval", + comment: "重试间隔(秒)" + }) + retryInterval!: number; + + @Column({ + type: DataType.SMALLINT, + comment: "状态: 1-启用 0-停用" + }) + status!: number; + + @Column({ + type: DataType.STRING(255), + comment: "备注" + }) + remark!: string | null; + + @Column({ + type: DataType.DATE, + field: "last_run_at", + comment: "上次执行时间" + }) + lastRunAt!: Date | null; + + @Column({ + type: DataType.SMALLINT, + field: "last_run_status", + comment: "上次执行状态: 1-成功 2-失败 3-跳过" + }) + lastRunStatus!: number | null; + + @Column({ + type: DataType.INTEGER, + field: "last_run_cost_ms", + comment: "上次执行耗时(毫秒)" + }) + lastRunCostMs!: number | null; + + @Column({ + type: DataType.TEXT, + field: "last_error", + comment: "上次失败原因" + }) + lastError!: string | null; + + @CreatedAt + created_at!: Date; + + @UpdatedAt + updated_at!: Date; +} diff --git a/server/src/entity/sys_job_log.entity.ts b/server/src/entity/sys_job_log.entity.ts new file mode 100644 index 0000000..53a0ad3 --- /dev/null +++ b/server/src/entity/sys_job_log.entity.ts @@ -0,0 +1,94 @@ +import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript"; + +@Table({ + tableName: "sys_job_log", + comment: "定时任务执行日志表", + timestamps: true, + createdAt: "created_at", + updatedAt: false +}) +export default class SysJobLog extends Model { + @Column({ + type: DataType.BIGINT, + autoIncrement: true, + primaryKey: true + }) + id!: number; + + @Column({ + type: DataType.BIGINT, + field: "job_id", + comment: "任务ID" + }) + jobId!: number; + + @Column({ + type: DataType.STRING(64), + field: "job_key", + comment: "任务键" + }) + jobKey!: string; + + @Column({ + type: DataType.STRING(64), + field: "job_name", + comment: "任务名称" + }) + jobName!: string; + + @Column({ + type: DataType.SMALLINT, + field: "trigger_type", + comment: "触发方式: 1-定时 2-手动 3-重试" + }) + triggerType!: number; + + @Column({ + type: DataType.SMALLINT, + comment: "执行状态: 1-成功 2-失败 3-跳过" + }) + status!: number; + + @Column({ + type: DataType.SMALLINT, + comment: "第几次尝试" + }) + attempt!: number; + + @Column({ + type: DataType.DATE, + field: "started_at", + comment: "开始时间" + }) + startedAt!: Date; + + @Column({ + type: DataType.DATE, + field: "finished_at", + comment: "结束时间" + }) + finishedAt!: Date; + + @Column({ + type: DataType.INTEGER, + field: "cost_ms", + comment: "耗时(毫秒)" + }) + costMs!: number; + + @Column({ + type: DataType.TEXT, + comment: "结果摘要" + }) + message!: string; + + @Column({ + type: DataType.TEXT, + field: "error_stack", + comment: "异常堆栈" + }) + errorStack!: string; + + @CreatedAt + created_at!: Date; +} diff --git a/server/src/entity/sys_notice.entity.ts b/server/src/entity/sys_notice.entity.ts new file mode 100644 index 0000000..983a00a --- /dev/null +++ b/server/src/entity/sys_notice.entity.ts @@ -0,0 +1,41 @@ +import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript"; + +@Table({ + tableName: "sys_notice", + comment: "站内通知表", + timestamps: true, + createdAt: "created_at", + updatedAt: false, + indexes: [ + { name: "idx_sys_notice_receiver", fields: ["receiver_id", "is_read"] }, + { name: "uk_sys_notice_biz", unique: true, fields: ["notice_type", "biz_id", "receiver_id"] } + ] +}) +export default class SysNotice extends Model { + @Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true }) + id!: number; + + @Column({ type: DataType.STRING(32), allowNull: false, field: "notice_type", comment: "通知类型:cert_expire证照到期" }) + noticeType!: string; + + @Column({ type: DataType.STRING(128), allowNull: false, field: "title", comment: "通知标题" }) + title!: string; + + @Column({ type: DataType.STRING(512), field: "content", comment: "通知内容" }) + content!: string; + + @Column({ type: DataType.STRING(32), field: "biz_type", comment: "业务类型:worker_cert电工资质证照" }) + bizType!: string; + + @Column({ type: DataType.BIGINT, field: "biz_id", comment: "业务主键(→ worker_cert.id)" }) + bizId!: number; + + @Column({ type: DataType.BIGINT, allowNull: false, field: "receiver_id", comment: "接收人(→ sys_user.id)" }) + receiverId!: number; + + @Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, field: "is_read", comment: "是否已读:0未读 1已读" }) + isRead!: number; + + @CreatedAt + created_at!: Date; +} \ No newline at end of file diff --git a/server/src/entity/worker.entity.ts b/server/src/entity/worker.entity.ts index a6ae015..d02cc7e 100644 --- a/server/src/entity/worker.entity.ts +++ b/server/src/entity/worker.entity.ts @@ -6,7 +6,10 @@ import { Column, Model, Table, DataType, CreatedAt, UpdatedAt } from "sequelize- timestamps: true, createdAt: "created_at", updatedAt: "updated_at", - indexes: [{ name: "idx_worker_phone", fields: ["phone"] }] + indexes: [ + { name: "idx_worker_phone", fields: ["phone"] }, + { name: "idx_worker_audit_status", fields: ["audit_status"] } + ] }) export default class Worker extends Model { @Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true }) @@ -39,6 +42,24 @@ export default class Worker extends Model { @Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, comment: "状态:1在岗 0离职" }) status!: number; + @Column({ type: DataType.STRING(255), field: "password_hash", comment: "电工APP登录密码bcrypt哈希" }) + passwordHash!: string; + + @Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, field: "audit_status", comment: "审核状态:0待审核 1已通过 2已驳回" }) + auditStatus!: number; + + @Column({ type: DataType.STRING(255), field: "audit_remark", comment: "驳回理由/审核备注" }) + auditRemark!: string; + + @Column({ type: DataType.BIGINT, field: "audit_by", comment: "审核人(→ sys_user.id)" }) + auditBy!: number; + + @Column({ type: DataType.DATE, field: "audit_at", comment: "审核时间" }) + auditAt!: Date; + + @Column({ type: DataType.DATE, field: "last_login_at", comment: "最后登录时间" }) + lastLoginAt!: Date; + @CreatedAt created_at!: Date; diff --git a/server/src/entity/worker_cert.entity.ts b/server/src/entity/worker_cert.entity.ts new file mode 100644 index 0000000..11de530 --- /dev/null +++ b/server/src/entity/worker_cert.entity.ts @@ -0,0 +1,47 @@ +import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, ForeignKey } from "sequelize-typescript"; +import Worker from "./worker.entity"; + +@Table({ + tableName: "worker_cert", + comment: "电工资质证照表", + timestamps: true, + createdAt: "created_at", + updatedAt: "updated_at", + indexes: [ + { name: "idx_worker_cert_worker", fields: ["worker_id"] }, + { name: "idx_worker_cert_expire", fields: ["expire_date"] }, + { name: "uk_worker_cert_type_no", unique: true, fields: ["worker_id", "cert_type", "cert_no"] } + ] +}) +export default class WorkerCert extends Model { + @Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true }) + id!: number; + + @ForeignKey(() => Worker) + @Column({ type: DataType.BIGINT, allowNull: false, field: "worker_id", comment: "共享电工ID(→ worker)" }) + workerId!: number; + + @Column({ type: DataType.STRING(64), field: "cert_type", comment: "证书类型(字典 worker_cert_type)" }) + certType!: string; + + @Column({ type: DataType.STRING(64), field: "cert_no", comment: "证书编号" }) + certNo!: string; + + @Column({ type: DataType.STRING(128), field: "issue_authority", comment: "发证机关" }) + issueAuthority!: string; + + @Column({ type: DataType.DATEONLY, field: "issue_date", comment: "发证日期" }) + issueDate!: string; + + @Column({ type: DataType.DATEONLY, field: "expire_date", comment: "有效期至(到期提醒依据;NULL=长期有效)" }) + expireDate!: string; + + @Column({ type: DataType.STRING(512), field: "cert_image_url", comment: "证照图片对象key" }) + certImageUrl!: string; + + @CreatedAt + created_at!: Date; + + @UpdatedAt + updated_at!: Date; +} \ No newline at end of file diff --git a/server/src/middleware/auth.middleware.ts b/server/src/middleware/auth.middleware.ts index 1788fd4..d55a911 100644 --- a/server/src/middleware/auth.middleware.ts +++ b/server/src/middleware/auth.middleware.ts @@ -5,8 +5,8 @@ import { TokenUtil } from '../util/token'; @Provide() export class AuthMiddleware implements IMiddleware { - // 中间件匹配范围:全局生效,但跳过公开接口 - ignore = ['/api/auth/*', '/api/health']; + // 中间件匹配范围:全局生效,但跳过公开接口与电工 APP 接口(电工 APP 走独立鉴权) + ignore = ['/api/auth/*', '/api/health', '/api/worker-app/*']; @Inject() tokenUtil!: TokenUtil; diff --git a/server/src/middleware/rbac.middleware.ts b/server/src/middleware/rbac.middleware.ts index e6cec2e..1231156 100644 --- a/server/src/middleware/rbac.middleware.ts +++ b/server/src/middleware/rbac.middleware.ts @@ -13,6 +13,7 @@ const PERMISSION_MAP: Record = { '/api/menus': 'system:menu', '/api/dicts': 'system:dict', '/api/logs': 'system:log', + '/api/jobs': 'system:job', '/api/customers': 'customer:list', '/api/customer-departments': 'customer:department', '/api/customer-accounts': 'customer:account', @@ -26,6 +27,9 @@ const PERMISSION_MAP: Record = { '/api/install-orders': 'project:install', '/api/acceptances': 'project:acceptance', '/api/visit-records': 'project:visit', + '/api/service-prices': 'dispatch:price', + '/api/dispatch-prices': 'dispatch:cost', + '/api/workers': 'dispatch:worker', }; // 超级管理员角色编码,拥有全部权限 @@ -33,8 +37,17 @@ const ADMIN_ROLE_CODE = 'ADMIN'; @Provide() export class RbacMiddleware implements IMiddleware { - // 中间件匹配范围:全局生效,跳过公开接口与仪表盘 - ignore = ['/api/auth/*', '/api/health', '/api/dashboard/*']; + // 中间件匹配范围:全局生效,跳过公开接口、仪表盘与电工 APP 接口 + // /api/workers/options 与 /api/workers/recommend 放行:业务员在「项目勘察派单」页 + // 仍可调用电工下拉与推荐接口,不受 dispatch:worker 权限限制(本次需求有意设计) + ignore = [ + '/api/auth/*', + '/api/health', + '/api/dashboard/*', + '/api/worker-app/*', + '/api/workers/options', + '/api/workers/recommend', + ]; @Inject() permissionService!: PermissionService; diff --git a/server/src/middleware/worker_auth.middleware.ts b/server/src/middleware/worker_auth.middleware.ts new file mode 100644 index 0000000..242d776 --- /dev/null +++ b/server/src/middleware/worker_auth.middleware.ts @@ -0,0 +1,68 @@ +import { Middleware, IMiddleware } from '@midwayjs/core'; +import { Context, NextFunction } from '@midwayjs/koa'; +import { Provide, Inject } from '@midwayjs/core'; +import { TokenUtil } from '../util/token'; + +// 电工 APP 独立鉴权中间件: +// 与管理端 sys_user 鉴权隔离,仅作用于 /api/worker-app/* 接口, +// 登录/注册(/api/worker-app/auth/*)无需鉴权。 +@Provide() +export class WorkerAuthMiddleware implements IMiddleware { + // 注意:Midway 不允许同时声明 match 与 ignore,故仅在 match 生效, + // 登录/注册路径(/api/worker-app/auth/*)在内部放行。 + match = ['/api/worker-app/*']; + + @Inject() + tokenUtil!: TokenUtil; + + resolve() { + return async (ctx: Context, next: NextFunction) => { + if (ctx.path.startsWith('/api/worker-app/auth/')) { + await next(); + return; + } + + const authHeader = ctx.header.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + ctx.status = 401; + ctx.body = { + code: 401, + message: '未登录或登录已过期', + }; + return; + } + + const token = authHeader.substring(7); + + let decoded: any; + try { + decoded = this.tokenUtil.verifyWorkerToken(token); + } catch (error: any) { + if (error.name === 'TokenExpiredError') { + ctx.status = 401; + ctx.body = { + code: 401, + message: '未登录或登录已过期', + expired: true, + }; + } else { + ctx.status = 401; + ctx.body = { + code: 401, + message: '未登录或登录已过期', + }; + } + return; + } + + // 写入当前电工上下文,供业务代码取用 + ctx.state.worker = { + workerId: decoded.workerId, + phone: decoded.phone, + }; + + await next(); + }; + } +} \ No newline at end of file diff --git a/server/src/service/dispatch_price.service.ts b/server/src/service/dispatch_price.service.ts new file mode 100644 index 0000000..bff5bfd --- /dev/null +++ b/server/src/service/dispatch_price.service.ts @@ -0,0 +1,135 @@ +import { Provide } from "@midwayjs/core"; +import { InjectRepository } from "@midwayjs/sequelize"; +import { Repository } from "sequelize-typescript"; +import { Op, UniqueConstraintError } from "sequelize"; +import { Context } from "@midwayjs/koa"; +import DispatchPrice from "../entity/dispatch_price.entity"; + +// 创建/更新可写入字段白名单(priceCode 只读,服务端生成) +const WRITABLE_FIELDS = ["dispatchType", "priceType", "priceMode", "unitPrice", "unit", "status", "remark"]; +// 计价方式 → 默认单位兜底 +const MODE_UNIT: Record = { 1: "次", 2: "小时" }; + +@Provide() +export class DispatchPriceService { + @InjectRepository(DispatchPrice) + dispatchPriceModel!: Repository; + + // 价格编码规则:DG + 东八区 yyyymmdd + 3 位序号(当日已有记录数 +1),唯一冲突时递增重试 1 次 + private async generatePriceCode(): Promise { + const now = new Date(); + const d = new Date(now.getTime() + 8 * 60 * 60 * 1000); + const yyyymmdd = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, "0")}${String(d.getUTCDate()).padStart(2, "0")}`; + const prefix = `DG${yyyymmdd}`; + + const count = await this.dispatchPriceModel.count({ where: { priceCode: { [Op.like]: `${prefix}%` } } }); + let seq = count + 1; + + for (let i = 0; i <= 1; i++) { + const code = `${prefix}${String(seq).padStart(3, "0")}`; + const exists = await this.dispatchPriceModel.findOne({ where: { priceCode: code } }); + if (!exists) return code; + seq++; + } + throw new Error("价格编码生成冲突,请重试"); + } + + // 仅保留白名单字段 + private pickWritable(data: any): any { + const payload: any = {}; + for (const key of WRITABLE_FIELDS) { + if (data[key] !== undefined) payload[key] = data[key]; + } + return payload; + } + + async list(query: any, ctx: Context): Promise { + const page = Number(query.page) || 1; + const pageSize = Math.min(Number(query.pageSize) || 10, 200); + + const where: any = {}; + if (query.keyword) { + where.priceCode = { [Op.like]: `%${query.keyword}%` }; + } + if (query.dispatchType !== undefined && query.dispatchType !== "") where.dispatchType = query.dispatchType; + if (query.priceMode !== undefined && query.priceMode !== "") where.priceMode = Number(query.priceMode); + if (query.status !== undefined && query.status !== "") where.status = Number(query.status); + + const { count, rows } = await this.dispatchPriceModel.findAndCountAll({ + where, + limit: pageSize, + offset: (page - 1) * pageSize, + order: [["id", "DESC"]] + }); + + return { + code: 200, + message: "获取派单成本价目列表成功", + data: { list: rows.map((r) => r.toJSON()), total: count, page: Number(page), pageSize: Number(pageSize) } + }; + } + + async detail(id: number): Promise { + const record = await this.dispatchPriceModel.findByPk(id); + if (!record) return { code: 404, message: "价目不存在", data: null }; + return { code: 200, message: "获取派单成本价目详情成功", data: record.toJSON() }; + } + + async create(data: any, ctx: Context): Promise { + if (!data.dispatchType) return { code: 400, message: "派单类型不能为空", data: null }; + if (data.unitPrice === undefined || data.unitPrice === null || data.unitPrice === "") { + return { code: 400, message: "单价不能为空", data: null }; + } + + const payload = this.pickWritable(data); + if (payload.priceMode === undefined || payload.priceMode === null) payload.priceMode = 1; + if (payload.status === undefined || payload.status === null) payload.status = 1; + if (!payload.unit) payload.unit = MODE_UNIT[Number(payload.priceMode)] || "次"; + + try { + payload.priceCode = await this.generatePriceCode(); + const created = await this.dispatchPriceModel.create(payload); + return { code: 200, message: "创建派单成本价目成功", data: created.toJSON() }; + } catch (err: any) { + // 唯一冲突:重新生成编码后重试 1 次 + if (err instanceof UniqueConstraintError) { + try { + payload.priceCode = await this.generatePriceCode(); + const created = await this.dispatchPriceModel.create(payload); + return { code: 200, message: "创建派单成本价目成功", data: created.toJSON() }; + } catch (_e) { + return { code: 400, message: "价格编码已存在", data: null }; + } + } + throw err; + } + } + + async update(id: number, data: any, ctx: Context): Promise { + const record = await this.dispatchPriceModel.findByPk(id); + if (!record) return { code: 404, message: "价目不存在", data: null }; + + const payload = this.pickWritable(data); + await record.update(payload); + + return { code: 200, message: "更新派单成本价目成功", data: record.toJSON() }; + } + + async delete(id: number, ctx: Context): Promise { + const record = await this.dispatchPriceModel.findByPk(id); + if (!record) return { code: 404, message: "价目不存在", data: null }; + + await record.destroy(); + return { code: 200, message: "删除派单成本价目成功", data: null }; + } + + async updateStatus(id: number, status: number, ctx: Context): Promise { + if (status !== 0 && status !== 1) return { code: 400, message: "状态值非法", data: null }; + + const record = await this.dispatchPriceModel.findByPk(id); + if (!record) return { code: 404, message: "价目不存在", data: null }; + + await record.update({ status }); + return { code: 200, message: "更新派单成本价目状态成功", data: null }; + } +} \ No newline at end of file diff --git a/server/src/service/job.service.ts b/server/src/service/job.service.ts new file mode 100644 index 0000000..718babb --- /dev/null +++ b/server/src/service/job.service.ts @@ -0,0 +1,231 @@ +import { Provide, Inject } from "@midwayjs/core"; +import { InjectRepository } from "@midwayjs/sequelize"; +import { Repository } from "sequelize-typescript"; +import { Op } from "sequelize"; +import dayjs from "dayjs"; +import SysJob from "../entity/sys_job.entity"; +import SysJobLog from "../entity/sys_job_log.entity"; +import { JobSchedulerService } from "./job_scheduler.service"; +import { getBuiltinJob } from "../task/job_registry"; +import { UpdateJobDTO, JobLogQueryDTO } from "../dto/job.dto"; + +@Provide() +export class JobService { + @InjectRepository(SysJob) + jobModel!: Repository; + + @InjectRepository(SysJobLog) + jobLogModel!: Repository; + + @Inject() + scheduler!: JobSchedulerService; + + /** 任务列表:按任务名称模糊 + 状态筛选,附带注册表信息与下次执行时间 */ + async list(query: { page?: any; pageSize?: any; jobName?: string; status?: any }): Promise { + const page = Number(query.page) || 1; + const pageSize = Number(query.pageSize) || 10; + + const where: any = {}; + + if (query.jobName) { + where.jobName = { [Op.like]: `%${query.jobName}%` }; + } + + if (query.status !== undefined && query.status !== null && `${query.status}` !== "") { + where.status = Number(query.status); + } + + const { count, rows } = await this.jobModel.findAndCountAll({ + where, + order: [["id", "ASC"]], + limit: pageSize, + offset: (page - 1) * pageSize + }); + + const list = rows.map(job => { + const builtin = getBuiltinJob(job.jobKey); + const registered = !!builtin; + + return { + ...job.toJSON(), + description: builtin ? builtin.description : null, + paramFields: builtin ? builtin.paramFields : [], + registered, + nextRunTime: registered && Number(job.status) === 1 ? this.scheduler.nextRunTime(job.cronExpression) : null + }; + }); + + return { + code: 200, + message: "获取定时任务列表成功", + data: { list, total: count, page, pageSize } + }; + } + + /** 更新任务配置:cron 合法性 + 参数白名单/范围校验,白名单之外的 key 直接丢弃 */ + async update(id: number, dto: UpdateJobDTO): Promise { + const job = await this.jobModel.findByPk(id); + + if (!job) { + return { code: 404, message: "定时任务不存在", data: null }; + } + + const builtin = getBuiltinJob(job.jobKey); + if (!builtin) { + return { code: 400, message: "任务实现不存在", data: null }; + } + + if (!this.scheduler.validateCron(dto.cronExpression)) { + return { code: 400, message: "cron 表达式不合法", data: null }; + } + + const inputParams: Record = + dto.params && typeof dto.params === "object" ? dto.params : {}; + const baseParams: Record = + job.params && typeof job.params === "object" ? job.params : {}; + + const params: Record = {}; + + for (const field of builtin.paramFields) { + const hasInput = Object.prototype.hasOwnProperty.call(inputParams, field.key); + + // 未提供:保留原值,其次取注册表默认参数 + if (!hasInput) { + if (Object.prototype.hasOwnProperty.call(baseParams, field.key)) { + params[field.key] = baseParams[field.key]; + } else if (Object.prototype.hasOwnProperty.call(builtin.defaultParams, field.key)) { + params[field.key] = builtin.defaultParams[field.key]; + } + continue; + } + + const value = inputParams[field.key]; + + if (field.type === "number") { + const num = Number(value); + if (value === null || value === "" || Number.isNaN(num)) { + return { code: 400, message: `参数 ${field.label} 必须为数字`, data: null }; + } + if (field.min !== undefined && num < field.min) { + return { code: 400, message: `参数 ${field.label} 取值范围为 ${field.min}-${field.max}`, data: null }; + } + if (field.max !== undefined && num > field.max) { + return { code: 400, message: `参数 ${field.label} 取值范围为 ${field.min}-${field.max}`, data: null }; + } + params[field.key] = num; + } else { + if (typeof value !== "string") { + return { code: 400, message: `参数 ${field.label} 必须为字符串`, data: null }; + } + params[field.key] = value; + } + } + + await job.update({ + cronExpression: dto.cronExpression, + params, + retryTimes: dto.retryTimes, + retryInterval: dto.retryInterval, + remark: dto.remark ?? null + } as any); + + await this.scheduler.reload(job.jobKey); + + return { code: 200, message: "更新定时任务成功", data: null }; + } + + /** 启停任务:更新状态后即时重载调度 */ + async changeStatus(id: number, status: number): Promise { + const job = await this.jobModel.findByPk(id); + + if (!job) { + return { code: 404, message: "定时任务不存在", data: null }; + } + + const builtin = getBuiltinJob(job.jobKey); + if (!builtin) { + return { code: 400, message: "任务实现不存在", data: null }; + } + + await job.update({ status } as any); + await this.scheduler.reload(job.jobKey); + + return { code: 200, message: "更新任务状态成功", data: null }; + } + + /** 立即执行一次(手动触发,trigger_type = 2) */ + async run(id: number): Promise { + const job = await this.jobModel.findByPk(id); + + if (!job) { + return { code: 404, message: "定时任务不存在", data: null }; + } + + const builtin = getBuiltinJob(job.jobKey); + if (!builtin) { + return { code: 400, message: "任务实现不存在", data: null }; + } + + const result = await this.scheduler.runOnce(Number(id), 2); + + return { code: 200, message: "任务执行完成", data: result }; + } + + /** 任务执行日志分页查询 */ + async listLogs(id: number, query: JobLogQueryDTO): Promise { + const job = await this.jobModel.findByPk(id); + + if (!job) { + return { code: 404, message: "定时任务不存在", data: null }; + } + + const page = Number(query.page) || 1; + const pageSize = Number(query.pageSize) || 10; + + const where: any = { jobId: Number(id) }; + + if (query.status !== undefined && query.status !== null && `${query.status}` !== "") { + where.status = Number(query.status); + } + + if (query.triggerType !== undefined && query.triggerType !== null && `${query.triggerType}` !== "") { + where.triggerType = Number(query.triggerType); + } + + if (query.startDate || query.endDate) { + where.created_at = {}; + if (query.startDate) { + where.created_at[Op.gte] = dayjs(query.startDate).startOf("day").toDate(); + } + if (query.endDate) { + where.created_at[Op.lte] = dayjs(query.endDate).endOf("day").toDate(); + } + } + + const { count, rows } = await this.jobLogModel.findAndCountAll({ + where, + order: [["created_at", "DESC"]], + limit: pageSize, + offset: (page - 1) * pageSize + }); + + return { + code: 200, + message: "获取任务日志成功", + data: { list: rows, total: count, page, pageSize } + }; + } + + /** 手动清理早于 days 天的任务日志 */ + async clearLogs(days: number = 90): Promise { + const cutoff = dayjs().subtract(days, "day").toDate(); + + const deleted = await this.jobLogModel.destroy({ + where: { + created_at: { [Op.lt]: cutoff } + } + }); + + return { code: 200, message: "清理任务日志成功", data: { deleted } }; + } +} diff --git a/server/src/service/job_scheduler.service.ts b/server/src/service/job_scheduler.service.ts new file mode 100644 index 0000000..3f805e5 --- /dev/null +++ b/server/src/service/job_scheduler.service.ts @@ -0,0 +1,429 @@ +import { Provide, App, Scope, ScopeEnum } from "@midwayjs/core"; +import { Application } from "@midwayjs/koa"; +import { InjectRepository } from "@midwayjs/sequelize"; +import { CronJob, CronTime } from "cron"; +import { Op } from "sequelize"; +import dayjs from "dayjs"; +import SysJob from "../entity/sys_job.entity"; +import SysJobLog from "../entity/sys_job_log.entity"; +import { LogService } from "./log.service"; +import { BuiltinJob, JobHandlerContext, JobRuntime, getBuiltinJob } from "../task/job_registry"; + +/** 任务执行结果(一次尝试) */ +export interface JobRunResult { + status: number; // 1成功 2失败 3跳过 + message: string; // 结果摘要 + costMs: number; // 本次尝试耗时 + attempt: number; // 本次是第几次尝试 +} + +/** 调度时区(固定上海时区) */ +const TIMEZONE = "Asia/Shanghai"; +/** 异常堆栈最大存储长度 */ +const MAX_ERROR_STACK = 4000; + +/** + * 自研轻量调度器:任务清单来自代码注册表,调度配置持久化在 sys_job, + * 页面修改 cron / 启停后通过 reload 即时生效(无需重启)。 + */ +// 调度器必须是进程内单例:定时器注册表要能被「启动注册」和「接口重载」共享 +@Provide() +@Scope(ScopeEnum.Singleton) +export class JobSchedulerService { + @App() + app!: Application; + + @InjectRepository(SysJob) + jobModel!: typeof SysJob; + + @InjectRepository(SysJobLog) + jobLogModel!: typeof SysJobLog; + + /** jobKey → 定时器 */ + private timers = new Map(); + /** jobKey → 待执行的重试定时器 */ + private retryTimers = new Map(); + /** jobKey 重入锁 */ + private running = new Set(); + + /** 私有日志适配:注册表与任务经它输出日志,沿用仓库内 console 风格 */ + private readonly jobLogger = { + info: (m: string, ...a: any[]) => console.log(`[定时任务] ${m}`, ...a), + warn: (m: string, ...a: any[]) => console.warn(`[定时任务] ${m}`, ...a), + error: (m: string, ...a: any[]) => console.error(`[定时任务] ${m}`, ...a) + }; + + /** 应用启动时加载全部启用任务并注册调度;失败自动重试,最多 3 次,不阻塞启动 */ + async start(): Promise { + const maxAttempts = 3; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const jobs = await this.jobModel.findAll({ where: { status: 1 } }); + jobs.forEach(job => this.register(job)); + this.jobLogger.info(`调度器启动完成,已加载 ${jobs.length} 个任务`); + return; + } catch (err) { + if (attempt < maxAttempts) { + this.jobLogger.error(`调度器启动失败(第 ${attempt} 次尝试),5 秒后重试:`, err); + await new Promise(resolve => setTimeout(resolve, 5000)); + } else { + // 3 次仍失败仅记录,不抛出,避免阻塞应用启动 + console.error("[定时任务] 调度器启动失败,已达最大重试次数,应用继续启动:", err); + } + } + } + } + + /** 停止全部定时器(应用停止时调用) */ + async stop(): Promise { + for (const timer of this.timers.values()) { + try { + timer.stop(); + } catch (err) { + console.warn("[定时任务] 停止定时器失败:", err); + } + } + this.timers.clear(); + + for (const retryTimer of this.retryTimers.values()) { + clearTimeout(retryTimer); + } + this.retryTimers.clear(); + } + + /** 单个任务重新加载:停旧 → 按最新配置重新注册(停用则不注册) */ + async reload(jobKey: string): Promise { + this.unregister(jobKey); + + const job = await this.jobModel.findOne({ where: { jobKey } }); + if (!job || Number(job.status) !== 1) { + return; + } + + this.register(job); + } + + /** 立即执行一次入口(手动触发同样按配置重试,但不阻塞调用方) */ + async runOnce(jobId: number, triggerType: number): Promise { + return this.execute(jobId, triggerType); + } + + /** 校验 cron 表达式合法性 */ + validateCron(expression: string): boolean { + if (!expression || typeof expression !== "string") { + return false; + } + + try { + new CronTime(expression, TIMEZONE); + return true; + } catch { + return false; + } + } + + /** 计算下次执行时间;表达式非法返回 null */ + nextRunTime(cronExpression: string): Date | null { + if (!this.validateCron(cronExpression)) { + return null; + } + + try { + // luxon 3.x 中 toDate 已移除,使用 toJSDate + return new CronTime(cronExpression, TIMEZONE).sendAt().toJSDate(); + } catch { + return null; + } + } + + /** 清理 sys_job_log 中早于 keepDays 天的记录,返回删除条数 */ + async cleanJobLogs(keepDays: number): Promise { + const cutoff = dayjs().subtract(keepDays, "day").toDate(); + + return this.jobLogModel.destroy({ + where: { + created_at: { [Op.lt]: cutoff } + } + }); + } + + /** 按任务配置注册定时器 */ + private register(job: SysJob): void { + this.unregister(job.jobKey); + + const builtin = getBuiltinJob(job.jobKey); + if (!builtin) { + this.jobLogger.warn(`任务 ${job.jobKey} 未在注册表中实现,跳过调度注册`); + return; + } + + if (!this.validateCron(job.cronExpression)) { + this.jobLogger.warn(`任务 ${job.jobKey} 的 cron 表达式不合法(${job.cronExpression}),跳过调度注册`); + return; + } + + try { + const timer = new CronJob( + job.cronExpression, + () => { + void this.execute(job.id, 1); + }, + null, + true, + TIMEZONE + ); + this.timers.set(job.jobKey, timer); + this.jobLogger.info(`任务 ${job.jobKey} 已注册调度,cron=${job.cronExpression}`); + } catch (err) { + this.jobLogger.warn(`任务 ${job.jobKey} 定时器创建失败:`, err); + } + } + + /** 注销定时器与待执行的重试定时器(不清重入锁) */ + private unregister(jobKey: string): void { + const timer = this.timers.get(jobKey); + if (timer) { + try { + timer.stop(); + } catch (err) { + console.warn(`[定时任务] 停止任务 ${jobKey} 定时器失败:`, err); + } + this.timers.delete(jobKey); + } + + const retryTimer = this.retryTimers.get(jobKey); + if (retryTimer) { + clearTimeout(retryTimer); + this.retryTimers.delete(jobKey); + } + } + + /** 执行内核:重入保护 → 执行尝试 → 失败按配置进入重试链 */ + private async execute(jobId: number, triggerType: number): Promise { + const job = await this.jobModel.findByPk(jobId); + if (!job) { + return { status: 2, message: "任务不存在", costMs: 0, attempt: 0 }; + } + + const builtin = getBuiltinJob(job.jobKey); + if (!builtin) { + const now = new Date(); + await this.writeJobLog({ + job, + triggerType, + status: 2, + attempt: 1, + startedAt: now, + finishedAt: now, + costMs: 0, + message: "任务实现不存在", + errorStack: null + }); + await this.writeLastRun(job, { status: 2, message: "任务实现不存在", costMs: 0, attempt: 1 }); + return { status: 2, message: "任务实现不存在", costMs: 0, attempt: 1 }; + } + + // 并发保护:上一次尚未结束则本次跳过,且不改写 sys_job.last_* + if (this.running.has(job.jobKey)) { + const message = "上一次执行尚未结束,本次跳过"; + const now = new Date(); + await this.writeJobLog({ + job, + triggerType, + status: 3, + attempt: 1, + startedAt: now, + finishedAt: now, + costMs: 0, + message, + errorStack: null + }); + return { status: 3, message, costMs: 0, attempt: 1 }; + } + + this.running.add(job.jobKey); + // 失败且需重试时把锁移交给重试链,本次不释放 + let handedOverToRetry = false; + + try { + const result = await this.runAttempt(job, builtin, triggerType, 1); + + if (result.status !== 1 && Number(job.retryTimes) > 0) { + handedOverToRetry = true; + this.scheduleRetry(job, 2); + } else { + await this.writeLastRun(job, result); + } + + return { status: result.status, message: result.message, costMs: result.costMs, attempt: result.attempt }; + } catch (err) { + const e = err as any; + this.jobLogger.error(`任务 ${job.jobKey} 执行流程异常:`, err); + return { status: 2, message: e?.message ? String(e.message) : "执行失败", costMs: 0, attempt: 1 }; + } finally { + if (!handedOverToRetry) { + this.running.delete(job.jobKey); + } + } + } + + /** 重试链:失败重试(trigger_type = 3),重试期间保持重入锁 */ + private async continueChain(jobId: number, attempt: number, jobKeyHint?: string): Promise { + let jobKey = jobKeyHint; + let handedOverToRetry = false; + + try { + const job = await this.jobModel.findByPk(jobId); + if (!job) { + return; + } + jobKey = job.jobKey; + + const builtin = getBuiltinJob(job.jobKey); + if (!builtin) { + const now = new Date(); + await this.writeJobLog({ + job, + triggerType: 3, + status: 2, + attempt, + startedAt: now, + finishedAt: now, + costMs: 0, + message: "任务实现不存在", + errorStack: null + }); + await this.writeLastRun(job, { status: 2, message: "任务实现不存在", costMs: 0, attempt }); + return; + } + + const result = await this.runAttempt(job, builtin, 3, attempt); + + if (result.status !== 1 && attempt <= Number(job.retryTimes)) { + handedOverToRetry = true; + this.scheduleRetry(job, attempt + 1); + return; + } + + // 重试链结束(成功或超出重试次数)才回写任务的 last_run_* + await this.writeLastRun(job, result); + } catch (err) { + this.jobLogger.error(`任务重试执行异常:`, err); + } finally { + if (!handedOverToRetry && jobKey) { + this.running.delete(jobKey); + } + } + } + + /** 单次尝试:执行 handler 并写入一条执行日志 */ + private async runAttempt( + job: SysJob, + builtin: BuiltinJob, + triggerType: number, + attempt: number + ): Promise { + const startedAt = new Date(); + const startMs = Date.now(); + let status = 1; + let message = "执行成功"; + let errorStack: string | null = null; + let error: any; + + try { + const ctx: JobHandlerContext = { + jobKey: job.jobKey, + jobName: job.jobName, + triggerType, + attempt, + runtime: this.buildRuntime() + }; + const ret = await builtin.handler(job.params ?? {}, ctx); + message = typeof ret === "string" && ret ? ret : "执行成功"; + } catch (err) { + const e = err as any; + status = 2; + message = e?.message ? String(e.message) : "执行失败"; + errorStack = (e?.stack ? String(e.stack) : String(e)).slice(0, MAX_ERROR_STACK); + error = err; + } + + const finishedAt = new Date(); + const costMs = Date.now() - startMs; + + await this.writeJobLog({ job, triggerType, status, attempt, startedAt, finishedAt, costMs, message, errorStack }); + + return { status, message, costMs, attempt, error }; + } + + /** 写入一条执行日志(写入失败不影响任务执行) */ + private async writeJobLog(fields: { + job: SysJob; + triggerType: number; + status: number; + attempt: number; + startedAt: Date; + finishedAt: Date; + costMs: number; + message: string; + errorStack: string | null; + }): Promise { + try { + await this.jobLogModel.create({ + jobId: fields.job.id, + jobKey: fields.job.jobKey, + jobName: fields.job.jobName, + triggerType: fields.triggerType, + status: fields.status, + attempt: fields.attempt, + startedAt: fields.startedAt, + finishedAt: fields.finishedAt, + costMs: fields.costMs, + message: fields.message, + errorStack: fields.errorStack + } as any); + } catch (err) { + this.jobLogger.error(`任务 ${fields.job.jobKey} 执行日志写入失败:`, err); + } + } + + /** 回写任务上次执行信息 */ + private async writeLastRun(job: SysJob, result: JobRunResult): Promise { + await this.jobModel.update( + { + lastRunAt: new Date(), + lastRunStatus: result.status, + lastRunCostMs: result.costMs, + lastError: result.status === 1 ? null : result.message + } as any, + { where: { id: job.id } } + ); + } + + /** 排定一次重试(重试期间保持重入锁) */ + private scheduleRetry(job: SysJob, attempt: number): void { + const intervalSec = Math.max(10, Number(job.retryInterval) || 60); + + const retryTimer = setTimeout(() => { + this.retryTimers.delete(job.jobKey); + void this.continueChain(job.id, attempt, job.jobKey); + }, intervalSec * 1000); + + this.retryTimers.set(job.jobKey, retryTimer); + } + + /** 构造注入给任务的运行时能力 */ + private buildRuntime(): JobRuntime { + return { + logger: this.jobLogger, + // LogService 是请求作用域,单例中改为按需从匿名请求上下文获取 + cleanOperationLogs: async (keepDays: number) => { + const ctx = this.app.createAnonymousContext(); + const logService = (await ctx.requestContext.getAsync(LogService)) as LogService; + return logService.cleanExpiredLogs(keepDays); + }, + cleanJobLogs: (keepDays: number) => this.cleanJobLogs(keepDays) + }; + } +} diff --git a/server/src/service/notice.service.ts b/server/src/service/notice.service.ts new file mode 100644 index 0000000..a897682 --- /dev/null +++ b/server/src/service/notice.service.ts @@ -0,0 +1,251 @@ +import { Provide } from "@midwayjs/core"; +import { InjectRepository } from "@midwayjs/sequelize"; +import { Repository } from "sequelize-typescript"; +import { Op } from "sequelize"; +import dayjs from "dayjs"; +import SysNotice from "../entity/sys_notice.entity"; +import WorkerCert from "../entity/worker_cert.entity"; +import Worker from "../entity/worker.entity"; +import SysUser from "../entity/sysUser.entity"; +import SysUserRole from "../entity/sysUserRole.entity"; +import SysRole from "../entity/sysRole.entity"; + +// 通知类型:证照到期提醒 +const NOTICE_TYPE_CERT_EXPIRE = "cert_expire"; +// 业务类型:电工资质证照 +const NOTICE_BIZ_TYPE_WORKER_CERT = "worker_cert"; +// 到期提醒提前天数 +const CERT_EXPIRE_WARN_DAYS = 30; +// 超级管理员角色编码(拥有全部权限,作为证照提醒接收人) +const ADMIN_ROLE_CODE = "ADMIN"; +// 分页上限,防止单次拉取过多数据 +const MAX_PAGE_SIZE = 200; + +// 归一化 DATEONLY 值为 YYYY-MM-DD 字符串(NULL/空 表示长期有效) +function toDateOnly(value: any): string { + if (value === null || value === undefined || value === "") return ""; + const str = String(value); + return str.length >= 10 ? str.slice(0, 10) : str; +} + +@Provide() +export class NoticeService { + @InjectRepository(SysNotice) + noticeModel!: Repository; + + @InjectRepository(WorkerCert) + certModel!: Repository; + + @InjectRepository(Worker) + workerModel!: Repository; + + @InjectRepository(SysUser) + userModel!: Repository; + + @InjectRepository(SysUserRole) + userRoleModel!: Repository; + + @InjectRepository(SysRole) + roleModel!: Repository; + + // 我的通知列表:未读优先,可选按已读状态筛选 + async listMy(userId: number, query: { page?: any; pageSize?: any; isRead?: any }): Promise { + const page = Math.max(Number(query.page) || 1, 1); + const rawPageSize = Number(query.pageSize) || 10; + const pageSize = Math.min(Math.max(rawPageSize, 1), MAX_PAGE_SIZE); + + const where: any = { receiverId: userId }; + if (query.isRead !== undefined && query.isRead !== "") { + where.isRead = Number(query.isRead); + } + + const { count, rows } = await this.noticeModel.findAndCountAll({ + where, + limit: pageSize, + offset: (page - 1) * pageSize, + // 未读在前,其次按时间倒序 + order: [["is_read", "ASC"], ["id", "DESC"]] + }); + + return { + code: 200, + message: "获取通知列表成功", + data: { + list: rows.map((r) => r.toJSON()), + total: count, + page, + pageSize + } + }; + } + + // 未读数量(顶栏红点) + async unreadCount(userId: number): Promise { + const count = await this.noticeModel.count({ where: { receiverId: userId, isRead: 0 } }); + + return { + code: 200, + message: "获取未读通知数成功", + data: { count } + }; + } + + // 单条标记已读(仅能操作自己的通知) + async markRead(userId: number, id: number): Promise { + const notice = await this.noticeModel.findOne({ where: { id, receiverId: userId } }); + + if (!notice) { + return { + code: 404, + message: "通知不存在", + data: null + }; + } + + if (Number(notice.isRead) !== 1) { + await notice.update({ isRead: 1 }); + } + + return { + code: 200, + message: "标记已读成功", + data: null + }; + } + + // 全部标记已读 + async markAllRead(userId: number): Promise { + const updated = await this.noticeModel.update({ isRead: 1 }, { where: { receiverId: userId, isRead: 0 } }); + + return { + code: 200, + message: "全部标记已读成功", + data: { updated: Array.isArray(updated) ? updated[0] : 0 } + }; + } + + // 查询拥有 ADMIN 角色的所有用户ID(sys_user_role + sys_role 关联,并校验用户真实存在) + async getAdminUserIds(): Promise { + const adminRole = await this.roleModel.findOne({ + where: { roleCode: ADMIN_ROLE_CODE }, + attributes: ["id"] + }); + if (!adminRole) return []; + + const userRoles = await this.userRoleModel.findAll({ + where: { roleId: Number(adminRole.id) }, + attributes: ["userId"] + }); + const userIds = Array.from(new Set(userRoles.map((ur) => Number(ur.userId)))); + if (userIds.length === 0) return []; + + const users = await this.userModel.findAll({ + where: { id: { [Op.in]: userIds } }, + attributes: ["id"] + }); + + return users.map((u) => Number(u.id)); + } + + // 扫描 30 天内到期或已过期的电工资质证照,向所有 ADMIN 推送站内通知(幂等) + async scanCertExpireAndNotify(): Promise<{ scanned: number; created: number }> { + let scanned = 0; + let created = 0; + + try { + // 使用本地日期字符串比较,避免时区偏差 + const today = dayjs().format("YYYY-MM-DD"); + const deadline = dayjs().add(CERT_EXPIRE_WARN_DAYS, "day").format("YYYY-MM-DD"); + + // 1. 查出已设置有效期且在提醒窗口内的证书(含已过期) + const certs = await this.certModel.findAll({ + where: { + expireDate: { [Op.not]: null, [Op.lte]: deadline } + }, + order: [["expire_date", "ASC"]] + }); + scanned = certs.length; + if (scanned === 0) return { scanned, created }; + + // 2. worker_cert 未声明 BelongsTo(Worker) 关联,单独查询电工姓名后按 Map 关联,避免 N+1 + const workerIds = Array.from(new Set(certs.map((c) => Number(c.workerId)))); + const workers = await this.workerModel.findAll({ + where: { id: { [Op.in]: workerIds } }, + attributes: ["id", "workerName"] + }); + const workerNameMap = new Map(); + for (const w of workers) workerNameMap.set(Number(w.id), w.workerName); + + // 3. 接收人:全部 ADMIN + const adminIds = await this.getAdminUserIds(); + if (adminIds.length === 0) return { scanned, created }; + + // 4. 幂等:按 (noticeType, bizType, bizId, receiverId) 先批量查出已存在的通知,已存在则跳过 + const certIds = certs.map((c) => Number(c.id)); + const existing = await this.noticeModel.findAll({ + where: { + noticeType: NOTICE_TYPE_CERT_EXPIRE, + bizType: NOTICE_BIZ_TYPE_WORKER_CERT, + bizId: { [Op.in]: certIds }, + receiverId: { [Op.in]: adminIds } + }, + attributes: ["bizId", "receiverId"] + }); + const existsSet = new Set(existing.map((n) => `${Number(n.bizId)}_${Number(n.receiverId)}`)); + + // 5. 证书 × ADMIN 逐条生成通知 + for (const cert of certs) { + const expire = toDateOnly(cert.expireDate); + if (!expire) continue; + + const days = dayjs(expire).startOf("day").diff(dayjs(today).startOf("day"), "day"); + const workerName = workerNameMap.get(Number(cert.workerId)) || "未知电工"; + const certType = cert.certType || "资质证书"; + const title = + days < 0 + ? `电工【${workerName}】的${certType}已于 ${expire} 过期` + : `电工【${workerName}】的${certType}将于 ${expire} 到期(剩余 ${days} 天)`; + const content = [ + `证书编号:${cert.certNo || "—"}`, + `发证机关:${cert.issueAuthority || "—"}`, + `有效期至:${expire}`, + days < 0 ? `已过期 ${Math.abs(days)} 天,请及时提醒电工办理续期` : `剩余 ${days} 天,请及时提醒电工办理续期` + ].join(";"); + + for (const adminId of adminIds) { + if (existsSet.has(`${Number(cert.id)}_${adminId}`)) continue; + + await this.noticeModel.create({ + noticeType: NOTICE_TYPE_CERT_EXPIRE, + title, + content, + bizType: NOTICE_BIZ_TYPE_WORKER_CERT, + bizId: Number(cert.id), + receiverId: adminId, + isRead: 0 + } as any); + existsSet.add(`${Number(cert.id)}_${adminId}`); + created += 1; + } + } + + return { scanned, created }; + } catch (err: any) { + // 定时任务不允许抛出,失败仅记录日志 + console.error("[notice] 证照到期扫描失败:", err?.message || err); + return { scanned, created }; + } + } + + // 清理指定证书的到期通知(证书续期/删除后调用,允许重新提醒) + async clearByCertIds(certIds: number[]): Promise { + if (!certIds || certIds.length === 0) return 0; + + return await this.noticeModel.destroy({ + where: { + bizType: NOTICE_BIZ_TYPE_WORKER_CERT, + bizId: { [Op.in]: certIds } + } + }); + } +} \ No newline at end of file diff --git a/server/src/service/service_price.service.ts b/server/src/service/service_price.service.ts new file mode 100644 index 0000000..a6f2ef5 --- /dev/null +++ b/server/src/service/service_price.service.ts @@ -0,0 +1,138 @@ +import { Provide } from "@midwayjs/core"; +import { InjectRepository } from "@midwayjs/sequelize"; +import { Repository } from "sequelize-typescript"; +import { Op, UniqueConstraintError } from "sequelize"; +import { Context } from "@midwayjs/koa"; +import ServicePrice from "../entity/service_price.entity"; + +// 创建/更新可写入字段白名单(priceCode 只读,服务端生成) +const WRITABLE_FIELDS = ["priceName", "serviceType", "priceMode", "unitPrice", "unit", "status", "remark"]; +// 计价方式 → 默认单位兜底 +const MODE_UNIT: Record = { 1: "次", 2: "小时", 3: "个" }; + +@Provide() +export class ServicePriceService { + @InjectRepository(ServicePrice) + servicePriceModel!: Repository; + + // 价目编码规则:FW + 东八区 yyyymmdd + 3 位序号(当日已有记录数 +1),唯一冲突时递增重试 1 次 + private async generatePriceCode(): Promise { + const now = new Date(); + const d = new Date(now.getTime() + 8 * 60 * 60 * 1000); + const yyyymmdd = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, "0")}${String(d.getUTCDate()).padStart(2, "0")}`; + const prefix = `FW${yyyymmdd}`; + + const count = await this.servicePriceModel.count({ where: { priceCode: { [Op.like]: `${prefix}%` } } }); + let seq = count + 1; + + for (let i = 0; i <= 1; i++) { + const code = `${prefix}${String(seq).padStart(3, "0")}`; + const exists = await this.servicePriceModel.findOne({ where: { priceCode: code } }); + if (!exists) return code; + seq++; + } + throw new Error("价目编码生成冲突,请重试"); + } + + // 仅保留白名单字段 + private pickWritable(data: any): any { + const payload: any = {}; + for (const key of WRITABLE_FIELDS) { + if (data[key] !== undefined) payload[key] = data[key]; + } + return payload; + } + + async list(query: any, ctx: Context): Promise { + const page = Number(query.page) || 1; + const pageSize = Math.min(Number(query.pageSize) || 10, 200); + + const where: any = {}; + if (query.keyword) { + where[Op.or] = [ + { priceCode: { [Op.like]: `%${query.keyword}%` } }, + { priceName: { [Op.like]: `%${query.keyword}%` } } + ]; + } + if (query.priceMode !== undefined && query.priceMode !== "") where.priceMode = Number(query.priceMode); + if (query.serviceType !== undefined && query.serviceType !== "") where.serviceType = query.serviceType; + if (query.status !== undefined && query.status !== "") where.status = Number(query.status); + + const { count, rows } = await this.servicePriceModel.findAndCountAll({ + where, + limit: pageSize, + offset: (page - 1) * pageSize, + order: [["id", "DESC"]] + }); + + return { + code: 200, + message: "获取服务价目列表成功", + data: { list: rows.map((r) => r.toJSON()), total: count, page: Number(page), pageSize: Number(pageSize) } + }; + } + + async detail(id: number): Promise { + const record = await this.servicePriceModel.findByPk(id); + if (!record) return { code: 404, message: "价目不存在", data: null }; + return { code: 200, message: "获取服务价目详情成功", data: record.toJSON() }; + } + + async create(data: any, ctx: Context): Promise { + if (!data.priceName) return { code: 400, message: "价目名称不能为空", data: null }; + if (data.unitPrice === undefined || data.unitPrice === null || data.unitPrice === "") { + return { code: 400, message: "单价不能为空", data: null }; + } + + const payload = this.pickWritable(data); + if (payload.priceMode === undefined || payload.priceMode === null) payload.priceMode = 1; + if (payload.status === undefined || payload.status === null) payload.status = 1; + if (!payload.unit) payload.unit = MODE_UNIT[Number(payload.priceMode)] || "次"; + + try { + payload.priceCode = await this.generatePriceCode(); + const created = await this.servicePriceModel.create(payload); + return { code: 200, message: "创建服务价目成功", data: created.toJSON() }; + } catch (err: any) { + // 唯一冲突:重新生成编码后重试 1 次 + if (err instanceof UniqueConstraintError) { + try { + payload.priceCode = await this.generatePriceCode(); + const created = await this.servicePriceModel.create(payload); + return { code: 200, message: "创建服务价目成功", data: created.toJSON() }; + } catch (_e) { + return { code: 400, message: "价目编码已存在", data: null }; + } + } + throw err; + } + } + + async update(id: number, data: any, ctx: Context): Promise { + const record = await this.servicePriceModel.findByPk(id); + if (!record) return { code: 404, message: "价目不存在", data: null }; + + const payload = this.pickWritable(data); + await record.update(payload); + + return { code: 200, message: "更新服务价目成功", data: record.toJSON() }; + } + + async delete(id: number, ctx: Context): Promise { + const record = await this.servicePriceModel.findByPk(id); + if (!record) return { code: 404, message: "价目不存在", data: null }; + + await record.destroy(); + return { code: 200, message: "删除服务价目成功", data: null }; + } + + async updateStatus(id: number, status: number, ctx: Context): Promise { + if (status !== 0 && status !== 1) return { code: 400, message: "状态值非法", data: null }; + + const record = await this.servicePriceModel.findByPk(id); + if (!record) return { code: 404, message: "价目不存在", data: null }; + + await record.update({ status }); + return { code: 200, message: "更新服务价目状态成功", data: null }; + } +} \ No newline at end of file diff --git a/server/src/service/worker.service.ts b/server/src/service/worker.service.ts index 840316d..b367b82 100644 --- a/server/src/service/worker.service.ts +++ b/server/src/service/worker.service.ts @@ -3,7 +3,14 @@ import { InjectRepository } from "@midwayjs/sequelize"; import { Repository } from "sequelize-typescript"; import { Op } from "sequelize"; import { Context } from "@midwayjs/koa"; +import dayjs from "dayjs"; import Worker from "../entity/worker.entity"; +import WorkerCert from "../entity/worker_cert.entity"; +import SysNotice from "../entity/sys_notice.entity"; +import SurveyOrder from "../entity/survey_order.entity"; +import InstallOrder from "../entity/install_order.entity"; +import { WorkerCreateDTO, WorkerUpdateDTO, WorkerAuditDTO, WorkerCertDTO } from "../dto/worker.dto"; +import { hashPassword } from "../util/crypto"; // 电工状态:1在岗 0离职 const STATUS_ON_DUTY = 1; @@ -12,41 +19,421 @@ const RECOMMEND_LIMIT = 5; // 经验分基准场次(达到即满分) const EXP_BASE_COUNT = 50; +// 审核状态:0待审核 1已通过 2已驳回 +const AUDIT_PENDING = 0; +const AUDIT_APPROVED = 1; +const AUDIT_REJECTED = 2; + +// 资质状态:none无证书 normal正常 expiring临期 expired已过期 +const CERT_STATUS_NONE = "none"; +const CERT_STATUS_NORMAL = "normal"; +const CERT_STATUS_EXPIRING = "expiring"; +const CERT_STATUS_EXPIRED = "expired"; + +// 临期预警天数(today <= expireDate <= today+30 视为临期) +const CERT_EXPIRE_WARN_DAYS = 30; + +// 到期提醒通知标识 +const NOTICE_TYPE_CERT_EXPIRE = "cert_expire"; +const NOTICE_BIZ_TYPE_WORKER_CERT = "worker_cert"; + +// 默认登录密码:手机号后 6 位,不足 6 位用 123456 +function getDefaultPassword(phone: string): string { + return phone.length >= 6 ? phone.slice(-6) : "123456"; +} + +// 归一化 DATEONLY 值为 YYYY-MM-DD 字符串(NULL/空 表示长期有效) +function toDateOnly(value: any): string | null { + if (value === null || value === undefined || value === "") return null; + const str = String(value); + return str.length >= 10 ? str.slice(0, 10) : str; +} + +// 按电工分组证书 +function groupCertsByWorker(certs: WorkerCert[]): Map { + const map = new Map(); + for (const cert of certs) { + const key = Number(cert.workerId); + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(cert); + } + return map; +} + +// 由证书的 expire_date 推导资质状态,优先级 expired > expiring > normal > none +function computeCertStatus(certs: Array<{ expireDate?: any }>, today: string, warn: string): string { + if (!certs || certs.length === 0) return CERT_STATUS_NONE; + let hasExpiring = false; + for (const cert of certs) { + const expire = toDateOnly(cert.expireDate); + // expire_date 为 NULL 视为长期有效,计为正常 + if (!expire) continue; + // YYYY-MM-DD 字符串可直接字典序比较 + if (expire < today) return CERT_STATUS_EXPIRED; + if (expire <= warn) hasExpiring = true; + } + return hasExpiring ? CERT_STATUS_EXPIRING : CERT_STATUS_NORMAL; +} + @Provide() export class WorkerService { @InjectRepository(Worker) workerModel!: Repository; - // 列表:姓名/手机号/资质模糊检索 + 状态过滤 + 分页 + @InjectRepository(WorkerCert) + certModel!: Repository; + + @InjectRepository(SysNotice) + noticeModel!: Repository; + + @InjectRepository(SurveyOrder) + surveyOrderModel!: Repository; + + @InjectRepository(InstallOrder) + installOrderModel!: Repository; + + // 资质状态推导所需的日期边界 + private certDateRange(): { today: string; warn: string } { + return { + today: dayjs().format("YYYY-MM-DD"), + warn: dayjs().add(CERT_EXPIRE_WARN_DAYS, "day").format("YYYY-MM-DD") + }; + } + + // 剔除敏感字段 + private cleanWorker(worker: Worker): any { + const data = worker.toJSON ? worker.toJSON() : { ...worker }; + delete data.passwordHash; + return data; + } + + // 证书列表摘要(列表页使用) + private toCertSummary(certs: WorkerCert[]): any[] { + return certs.map((c) => ({ id: c.id, certType: c.certType, certNo: c.certNo, expireDate: c.expireDate })); + } + + // 一次性批量加载证书并按电工分组,避免 N+1 + private async loadCertMap(workerIds: number[]): Promise> { + if (!workerIds || workerIds.length === 0) return new Map(); + const certs = await this.certModel.findAll({ where: { workerId: { [Op.in]: workerIds } } }); + return groupCertsByWorker(certs); + } + + // 清理证照到期提醒(续期/删除后允许重新提醒) + private async clearCertNotices(certIds: number[]): Promise { + if (!certIds || certIds.length === 0) return; + await this.noticeModel.destroy({ + where: { + noticeType: NOTICE_TYPE_CERT_EXPIRE, + bizType: NOTICE_BIZ_TYPE_WORKER_CERT, + bizId: { [Op.in]: certIds } + } + }); + } + + // 构建证书写入字段 + private buildCertPayload(workerId: number, dto: WorkerCertDTO): any { + return { + workerId, + certType: dto.certType, + certNo: dto.certNo, + issueAuthority: dto.issueAuthority, + issueDate: dto.issueDate, + expireDate: dto.expireDate, + certImageUrl: dto.certImageUrl + }; + } + + // 列表:姓名/手机号模糊检索 + 状态/审核状态/资质状态过滤 + 分页 async list(query: any, ctx: Context): Promise { const page = Number(query.page) || 1; - const pageSize = Number(query.pageSize || query.limit) || 10; + const rawPageSize = Number(query.pageSize || query.limit) || 10; + const pageSize = Math.min(Math.max(rawPageSize, 1), 200); const where: any = {}; if (query.keyword) { where[Op.or] = [ { workerName: { [Op.like]: `%${query.keyword}%` } }, - { phone: { [Op.like]: `%${query.keyword}%` } }, - { certInfo: { [Op.like]: `%${query.keyword}%` } } + { phone: { [Op.like]: `%${query.keyword}%` } } ]; } if (query.status !== undefined && query.status !== "") where.status = Number(query.status); + if (query.auditStatus !== undefined && query.auditStatus !== "") where.auditStatus = Number(query.auditStatus); + const certStatus: string = query.certStatus || ""; + const { today, warn } = this.certDateRange(); + + // 资质状态筛选:先取全部命中基础条件的电工,内存推导状态后过滤再分页(保证 total 正确) + if (certStatus) { + const rows = await this.workerModel.findAll({ + where, + attributes: { exclude: ["passwordHash"] }, + order: [["rating", "DESC"]] + }); + const certMap = await this.loadCertMap(rows.map((r) => Number(r.id))); + const filtered = rows.filter((r) => computeCertStatus(certMap.get(Number(r.id)) || [], today, warn) === certStatus); + const pageRows = filtered.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize); + const list = pageRows.map((r) => { + const certs = certMap.get(Number(r.id)) || []; + return { + ...this.cleanWorker(r), + certStatus: computeCertStatus(certs, today, warn), + certs: this.toCertSummary(certs) + }; + }); + return { + code: 200, + message: "获取共享电工列表成功", + data: { list, total: filtered.length, page, pageSize } + }; + } + + // 常规分页:仅对当前页电工批量加载证书 const { count, rows } = await this.workerModel.findAndCountAll({ where, + attributes: { exclude: ["passwordHash"] }, limit: pageSize, offset: (page - 1) * pageSize, order: [["rating", "DESC"]], distinct: true }); + const certMap = await this.loadCertMap(rows.map((r) => Number(r.id))); + const list = rows.map((r) => { + const certs = certMap.get(Number(r.id)) || []; + return { + ...this.cleanWorker(r), + certStatus: computeCertStatus(certs, today, warn), + certs: this.toCertSummary(certs) + }; + }); - return { code: 200, message: "获取共享电工列表成功", data: { list: rows.map((r) => r.toJSON()), total: count, page, pageSize } }; + return { code: 200, message: "获取共享电工列表成功", data: { list, total: count, page, pageSize } }; } - // 下拉选项:仅在岗电工 + // KPI 统计:总电工数 / 待审核 / 临期 / 已过期 + async stats(): Promise { + const total = await this.workerModel.count(); + const pendingAudit = await this.workerModel.count({ where: { auditStatus: AUDIT_PENDING } }); + + // 一次性取出全部证书,内存按电工分组统计(避免 N+1) + const workers = await this.workerModel.findAll({ attributes: ["id"] }); + const validIds = new Set(workers.map((w) => Number(w.id))); + const certs = await this.certModel.findAll({ attributes: ["workerId", "expireDate"] }); + const certMap = groupCertsByWorker(certs); + const { today, warn } = this.certDateRange(); + + let expiring = 0; + let expired = 0; + for (const [workerId, list] of certMap) { + if (!validIds.has(workerId)) continue; + const status = computeCertStatus(list, today, warn); + if (status === CERT_STATUS_EXPIRING) expiring += 1; + else if (status === CERT_STATUS_EXPIRED) expired += 1; + } + + return { code: 200, message: "获取电工统计成功", data: { total, pendingAudit, expiring, expired } }; + } + + // 详情:含完整证书数组 + async detail(id: number): Promise { + const worker = await this.workerModel.findByPk(id, { attributes: { exclude: ["passwordHash"] } }); + if (!worker) { + return { code: 404, message: "电工不存在", data: null }; + } + const certs = await this.certModel.findAll({ where: { workerId: id }, order: [["expireDate", "ASC"]] }); + const { today, warn } = this.certDateRange(); + + return { + code: 200, + message: "获取电工详情成功", + data: { + ...this.cleanWorker(worker), + certStatus: computeCertStatus(certs, today, warn), + certs + } + }; + } + + // 新增电工:手机号唯一,审核状态固定待审核、状态默认离职(0) + async create(dto: WorkerCreateDTO): Promise { + const phone = dto.phone; + const existing = await this.workerModel.findOne({ where: { phone }, attributes: ["id"] }); + if (existing) { + return { code: 400, message: "手机号已存在", data: null }; + } + + const payload: any = { + workerName: dto.workerName, + phone, + idCard: dto.idCard, + workerTitle: dto.workerTitle, + status: 0, + auditStatus: AUDIT_PENDING + }; + if (dto.rating !== undefined) payload.rating = dto.rating; + if (dto.certInfo !== undefined) payload.certInfo = dto.certInfo; + // 开通电工APP账号:默认密码为手机号后 6 位 + if (dto.withAccount === true) payload.passwordHash = await hashPassword(getDefaultPassword(phone)); + + const worker = await this.workerModel.create(payload); + + if (Array.isArray(dto.certs) && dto.certs.length > 0) { + await this.certModel.bulkCreate(dto.certs.map((c) => this.buildCertPayload(Number(worker.id), c)) as any); + } + + return { code: 200, message: "创建电工成功", data: this.cleanWorker(worker) }; + } + + // 更新电工:字段白名单;证书存在时整体覆盖 + async update(id: number, dto: WorkerUpdateDTO): Promise { + const worker = await this.workerModel.findByPk(id); + if (!worker) { + return { code: 404, message: "电工不存在", data: null }; + } + + if (dto.phone !== undefined && dto.phone !== worker.phone) { + const conflict = await this.workerModel.findOne({ where: { phone: dto.phone }, attributes: ["id"] }); + if (conflict && Number(conflict.id) !== Number(id)) { + return { code: 400, message: "手机号已存在", data: null }; + } + } + + const payload: any = {}; + if (dto.workerName !== undefined) payload.workerName = dto.workerName; + if (dto.phone !== undefined) payload.phone = dto.phone; + if (dto.idCard !== undefined) payload.idCard = dto.idCard; + if (dto.workerTitle !== undefined) payload.workerTitle = dto.workerTitle; + if (dto.status !== undefined) payload.status = dto.status; + if (dto.rating !== undefined) payload.rating = dto.rating; + if (dto.certInfo !== undefined) payload.certInfo = dto.certInfo; + + await worker.update(payload); + + // 整体覆盖证书:先删后插,并清理被删证书的到期提醒 + if (Array.isArray(dto.certs)) { + const oldCerts = await this.certModel.findAll({ where: { workerId: id }, attributes: ["id"] }); + await this.clearCertNotices(oldCerts.map((c) => Number(c.id))); + await this.certModel.destroy({ where: { workerId: id } }); + if (dto.certs.length > 0) { + await this.certModel.bulkCreate(dto.certs.map((c) => this.buildCertPayload(Number(id), c)) as any); + } + } + + return { code: 200, message: "更新电工成功", data: this.cleanWorker(worker) }; + } + + // 删除电工:存在派单记录则拒绝;否则级联删除证书并清理通知 + async remove(id: number): Promise { + const worker = await this.workerModel.findByPk(id); + if (!worker) { + return { code: 404, message: "电工不存在", data: null }; + } + + const [surveyRef, installRef] = await Promise.all([ + this.surveyOrderModel.findOne({ where: { workerId: id }, attributes: ["id"] }), + this.installOrderModel.findOne({ where: { workerId: id }, attributes: ["id"] }) + ]); + if (surveyRef || installRef) { + return { code: 400, message: "该电工存在派单记录,无法删除", data: null }; + } + + const certs = await this.certModel.findAll({ where: { workerId: id }, attributes: ["id"] }); + await this.clearCertNotices(certs.map((c) => Number(c.id))); + await this.certModel.destroy({ where: { workerId: id } }); + await worker.destroy(); + + return { code: 200, message: "删除电工成功", data: null }; + } + + // 资质审核 + async audit(id: number, dto: WorkerAuditDTO, operatorUserId: number): Promise { + const worker = await this.workerModel.findByPk(id); + if (!worker) { + return { code: 404, message: "电工不存在", data: null }; + } + + const approved = dto.approved === true; + if (approved) { + await worker.update({ + auditStatus: AUDIT_APPROVED, + auditBy: operatorUserId, + auditAt: new Date(), + auditRemark: null, + certVerified: true + }); + } else { + const remark = (dto.remark || "").trim(); + if (!remark) { + return { code: 400, message: "请填写驳回理由", data: null }; + } + await worker.update({ + auditStatus: AUDIT_REJECTED, + auditRemark: remark, + auditBy: operatorUserId, + auditAt: new Date(), + certVerified: false + }); + } + + return { + code: 200, + message: approved ? "审核通过成功" : "审核驳回成功", + data: this.cleanWorker(worker) + }; + } + + // 证书新增/更新(certId 缺省为新增) + async saveCert(workerId: number, dto: WorkerCertDTO, certId?: number): Promise { + const worker = await this.workerModel.findByPk(workerId, { attributes: ["id"] }); + if (!worker) { + return { code: 404, message: "电工不存在", data: null }; + } + + if (certId !== undefined) { + const cert = await this.certModel.findByPk(certId); + if (!cert || Number(cert.workerId) !== Number(workerId)) { + return { code: 403, message: "无权操作该证书", data: null }; + } + + const payload: any = {}; + if (dto.certType !== undefined) payload.certType = dto.certType; + if (dto.certNo !== undefined) payload.certNo = dto.certNo; + if (dto.issueAuthority !== undefined) payload.issueAuthority = dto.issueAuthority; + if (dto.issueDate !== undefined) payload.issueDate = dto.issueDate; + if (dto.certImageUrl !== undefined) payload.certImageUrl = dto.certImageUrl; + // 有效期变更时清理旧到期提醒,便于续期后重新提醒 + if (dto.expireDate !== undefined) { + payload.expireDate = dto.expireDate; + if (toDateOnly(dto.expireDate) !== toDateOnly(cert.expireDate)) { + await this.clearCertNotices([certId]); + } + } + await cert.update(payload); + + return { code: 200, message: "更新证书成功", data: cert }; + } + + const created = await this.certModel.create(this.buildCertPayload(workerId, dto) as any); + return { code: 200, message: "新增证书成功", data: created }; + } + + // 删除证书(校验归属) + async removeCert(workerId: number, certId: number): Promise { + const cert = await this.certModel.findByPk(certId); + if (!cert || Number(cert.workerId) !== Number(workerId)) { + return { code: 403, message: "无权操作该证书", data: null }; + } + + await this.clearCertNotices([certId]); + await cert.destroy(); + + return { code: 200, message: "删除证书成功", data: null }; + } + + // 下拉选项:仅在岗且审核通过的电工 async options(ctx: Context): Promise { const rows = await this.workerModel.findAll({ - where: { status: STATUS_ON_DUTY }, + where: { status: STATUS_ON_DUTY, auditStatus: AUDIT_APPROVED }, attributes: ["id", "workerName", "workerTitle", "rating", "certInfo", "certVerified", "completedCount"], order: [["rating", "DESC"]] }); @@ -55,7 +442,7 @@ export class WorkerService { // 智能推荐:评分 50% + 资质 30% + 经验 20% 加权,取前 5 名 async recommend(query: any, ctx: Context): Promise { - const rows = await this.workerModel.findAll({ where: { status: STATUS_ON_DUTY } }); + const rows = await this.workerModel.findAll({ where: { status: STATUS_ON_DUTY, auditStatus: AUDIT_APPROVED } }); if (rows.length === 0) return { code: 200, message: "获取推荐电工成功", data: [] }; const certKeyword: string = query.certKeyword || ""; @@ -95,4 +482,4 @@ export class WorkerService { return { code: 200, message: "获取推荐电工成功", data }; } -} +} \ No newline at end of file diff --git a/server/src/service/worker_app.service.ts b/server/src/service/worker_app.service.ts new file mode 100644 index 0000000..404bad1 --- /dev/null +++ b/server/src/service/worker_app.service.ts @@ -0,0 +1,326 @@ +import { Provide, Inject } from "@midwayjs/core"; +import { InjectRepository } from "@midwayjs/sequelize"; +import { Repository } from "sequelize-typescript"; +import Worker from "../entity/worker.entity"; +import WorkerCert from "../entity/worker_cert.entity"; +import SysNotice from "../entity/sys_notice.entity"; +import { TokenUtil } from "../util/token"; +import { hashPassword, comparePassword } from "../util/crypto"; +import { + WorkerRegisterDTO, + WorkerLoginDTO, + WorkerProfileUpdateDTO, + WorkerAppCertDTO +} from "../dto/worker_app.dto"; + +// 资质临期提醒阈值(天) +const EXPIRING_DAYS = 30; +// 证照到期通知类型 +const NOTICE_TYPE_CERT_EXPIRE = "cert_expire"; +// 证照业务类型 +const BIZ_TYPE_WORKER_CERT = "worker_cert"; + +@Provide() +export class WorkerAppService { + @Inject() + tokenUtil!: TokenUtil; + + @InjectRepository(Worker) + workerModel!: Repository; + + @InjectRepository(WorkerCert) + certModel!: Repository; + + @InjectRepository(SysNotice) + noticeModel!: Repository; + + // 电工注册:手机号唯一校验,初始为待审核(0)且离职(0) + async register(dto: WorkerRegisterDTO): Promise { + const exist = await this.workerModel.findOne({ where: { phone: dto.phone } }); + if (exist) { + return { code: 400, message: "手机号已注册" }; + } + + const passwordHash = await hashPassword(dto.password); + const worker = await this.workerModel.create({ + workerName: dto.workerName, + phone: dto.phone, + idCard: dto.idCard || null, + workerTitle: dto.workerTitle || null, + passwordHash, + auditStatus: 0, + status: 0 + } as any); + + return { + code: 200, + message: "注册成功,请等待审核", + data: this.basicInfo(worker) + }; + } + + // 电工登录:不因审核状态拦截,允许未通过/离职电工登录查看审核与资质 + async login(dto: WorkerLoginDTO): Promise { + const worker = await this.workerModel.findOne({ where: { phone: dto.phone } }); + if (!worker || !worker.passwordHash) { + return { code: 400, message: "手机号或密码错误" }; + } + + const isPasswordValid = await comparePassword(dto.password, worker.passwordHash); + if (!isPasswordValid) { + return { code: 400, message: "手机号或密码错误" }; + } + + const token = this.tokenUtil.generateWorkerToken({ + workerId: worker.id, + phone: worker.phone + }); + + await worker.update({ lastLoginAt: new Date() }); + + return { + code: 200, + message: "登录成功", + data: { + token, + workerInfo: this.basicInfo(worker) + } + }; + } + + // 个人资料:含审核信息与资质状态汇总 + async profile(workerId: number): Promise { + const worker = await this.workerModel.findByPk(workerId); + if (!worker) { + return { code: 404, message: "电工不存在" }; + } + + const certs = await this.listCertRows(workerId); + + return { + code: 200, + message: "获取成功", + data: { + ...this.basicInfo(worker), + idCard: worker.idCard, + auditStatus: worker.auditStatus, + auditRemark: worker.auditRemark, + certStatus: this.calcCertStatus(certs), + certs: certs.map(cert => cert.toJSON()) + } + }; + } + + // 更新个人资料:可改姓名/身份证/职衔,传 password 则重置密码 + async updateProfile(workerId: number, dto: WorkerProfileUpdateDTO): Promise { + const worker = await this.workerModel.findByPk(workerId); + if (!worker) { + return { code: 404, message: "电工不存在" }; + } + + const updateData: any = {}; + if (dto.workerName !== undefined) updateData.workerName = dto.workerName; + if (dto.idCard !== undefined) updateData.idCard = dto.idCard; + if (dto.workerTitle !== undefined) updateData.workerTitle = dto.workerTitle; + if (dto.password) updateData.passwordHash = await hashPassword(dto.password); + + await worker.update(updateData); + + return { + code: 200, + message: "更新成功", + data: this.basicInfo(worker) + }; + } + + // 资质证照列表 + async listCerts(workerId: number): Promise { + const certs = await this.listCertRows(workerId); + return { + code: 200, + message: "获取成功", + data: certs.map(cert => cert.toJSON()) + }; + } + + // 新增资质证照 + async createCert(workerId: number, dto: WorkerAppCertDTO): Promise { + const cert = await this.certModel.create({ + workerId, + certType: dto.certType, + certNo: dto.certNo || null, + issueAuthority: dto.issueAuthority || null, + issueDate: dto.issueDate || null, + expireDate: dto.expireDate || null, + certImageUrl: dto.certImageUrl || null + } as any); + + return { + code: 200, + message: "新增成功", + data: cert.toJSON() + }; + } + + // 修改资质证照:变更有效期时清除该证照的到期通知,以便重新提醒 + async updateCert(workerId: number, certId: number, dto: WorkerAppCertDTO): Promise { + const cert = await this.certModel.findByPk(certId); + if (!cert || cert.workerId !== workerId) { + return { code: 403, message: "无权操作该证书" }; + } + + const updateData: any = {}; + if (dto.certType !== undefined) updateData.certType = dto.certType; + if (dto.certNo !== undefined) updateData.certNo = dto.certNo; + if (dto.issueAuthority !== undefined) updateData.issueAuthority = dto.issueAuthority; + if (dto.issueDate !== undefined) updateData.issueDate = dto.issueDate; + if (dto.expireDate !== undefined) updateData.expireDate = dto.expireDate; + if (dto.certImageUrl !== undefined) updateData.certImageUrl = dto.certImageUrl; + + await cert.update(updateData); + + // 有效期变化后删除历史到期通知,续期后由后续任务重新生成提醒 + if (dto.expireDate !== undefined) { + await this.clearCertNotices(certId); + } + + return { + code: 200, + message: "更新成功", + data: cert.toJSON() + }; + } + + // 删除资质证照:归属校验通过后连带清除到期通知 + async removeCert(workerId: number, certId: number): Promise { + const cert = await this.certModel.findByPk(certId); + if (!cert || cert.workerId !== workerId) { + return { code: 403, message: "无权操作该证书" }; + } + + await cert.destroy(); + await this.clearCertNotices(certId); + + return { code: 200, message: "删除成功" }; + } + + // 重新提交审核:仅驳回(2)状态可退回待审核(0) + async resubmit(workerId: number): Promise { + const worker = await this.workerModel.findByPk(workerId); + if (!worker) { + return { code: 404, message: "电工不存在" }; + } + + if (worker.auditStatus !== 2) { + return { code: 400, message: "当前状态无需重新提交" }; + } + + await worker.update({ + auditStatus: 0, + auditRemark: null, + auditBy: null, + auditAt: null + } as any); + + return { code: 200, message: "已重新提交,请等待审核" }; + } + + // 到期提醒列表:基于 worker_cert 实时计算,包含临期与已过期(长期有效不计入) + async listNotices(workerId: number): Promise { + const certs = await this.listCertRows(workerId); + const today = this.todayStr(); + + const list = certs + .filter(cert => !!cert.expireDate) + .map(cert => { + const expireDate = String(cert.expireDate).slice(0, 10); + const remainDays = this.diffDays(today, expireDate); + return { + certId: cert.id, + certType: cert.certType, + certNo: cert.certNo, + expireDate, + remainDays, + isExpired: expireDate < today + }; + }) + .filter(item => item.isExpired || item.remainDays <= EXPIRING_DAYS) + .sort((a, b) => a.remainDays - b.remainDays); + + return { code: 200, message: "获取成功", data: list }; + } + + // 查询某电工的全部证照 + private async listCertRows(workerId: number): Promise { + return await this.certModel.findAll({ + where: { workerId }, + order: [["id", "DESC"]] + }); + } + + // 资质状态汇总:none 无证书 / expired 存在已过期 / expiring 存在临期 / normal 其余 + private calcCertStatus(certs: WorkerCert[]): string { + if (!certs || certs.length === 0) return "none"; + + const today = this.todayStr(); + const soon = this.addDays(today, EXPIRING_DAYS); + let hasExpiring = false; + + for (const cert of certs) { + // expire_date 为 NULL 视为长期有效 + if (!cert.expireDate) continue; + const expireDate = String(cert.expireDate).slice(0, 10); + if (expireDate < today) return "expired"; + if (expireDate <= soon) hasExpiring = true; + } + + return hasExpiring ? "expiring" : "normal"; + } + + // 删除某证照在 sys_notice 的到期通知 + private async clearCertNotices(certId: number): Promise { + await this.noticeModel.destroy({ + where: { + noticeType: NOTICE_TYPE_CERT_EXPIRE, + bizType: BIZ_TYPE_WORKER_CERT, + bizId: certId + } + }); + } + + // 电工基础信息(不含 passwordHash) + private basicInfo(worker: Worker): any { + return { + id: worker.id, + workerName: worker.workerName, + phone: worker.phone, + workerTitle: worker.workerTitle, + auditStatus: worker.auditStatus, + status: worker.status + }; + } + + private formatDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + } + + private todayStr(): string { + return this.formatDate(new Date()); + } + + private addDays(base: string, days: number): string { + const date = new Date(`${base}T00:00:00`); + date.setDate(date.getDate() + days); + return this.formatDate(date); + } + + // 返回 to - from 的天数差(正数为未来) + private diffDays(from: string, to: string): number { + const fromTime = new Date(`${from}T00:00:00`).getTime(); + const toTime = new Date(`${to}T00:00:00`).getTime(); + return Math.round((toTime - fromTime) / 86400000); + } +} \ No newline at end of file diff --git a/server/src/task/cert_expire.task.ts b/server/src/task/cert_expire.task.ts new file mode 100644 index 0000000..a4614fb --- /dev/null +++ b/server/src/task/cert_expire.task.ts @@ -0,0 +1,19 @@ +import { Inject } from "@midwayjs/core"; +import { Job, IJob } from "@midwayjs/cron"; +import { NoticeService } from "../service/notice.service"; + +/** + * 电工资质证照到期提醒定时任务 + * 每天 08:00 扫描 worker_cert 中 30 天内到期或已过期的证照, + * 为每个 ADMIN 生成一条站内通知(幂等,已存在的提醒不会重复生成)。 + */ +@Job({ cronTime: "0 0 8 * * *", start: true, timeZone: "Asia/Shanghai" }) +export class CertExpireTask implements IJob { + @Inject() + noticeService!: NoticeService; + + async onTick() { + const result = await this.noticeService.scanCertExpireAndNotify(); + console.log(`[cert-expire] 证照到期扫描完成:扫描 ${result.scanned} 条,新增通知 ${result.created} 条`); + } +} \ No newline at end of file diff --git a/server/src/task/demo_heartbeat.task.ts b/server/src/task/demo_heartbeat.task.ts new file mode 100644 index 0000000..61f44e8 --- /dev/null +++ b/server/src/task/demo_heartbeat.task.ts @@ -0,0 +1,24 @@ +import dayjs from "dayjs"; +import type { BuiltinJob } from "./job_registry"; + +/** + * 示例心跳任务:用于验证调度链路是否打通。 + * 默认每 5 分钟执行一次,写一条 info 日志并返回本次心跳时间。 + */ +export const demoHeartbeatJob: BuiltinJob = { + jobKey: "demo_heartbeat", + jobName: "示例心跳", + description: "用于验证调度链路的示例任务,按周期写入一条心跳日志", + defaultCron: "*/5 * * * *", + defaultParams: { message: "heartbeat" }, + paramFields: [ + { key: "message", label: "心跳内容", type: "string", placeholder: "默认 heartbeat" } + ], + handler: (params, ctx) => { + throw new Error("E2E 重试验证"); + const now = dayjs().format("YYYY-MM-DD HH:mm:ss"); + const message = params?.message ?? "heartbeat"; + ctx.runtime.logger.info(`[${ctx.jobKey}] 心跳 ${now}(message=${message})`); + return `心跳正常:${now}(message=${message})`; + } +}; diff --git a/server/src/task/job_registry.ts b/server/src/task/job_registry.ts new file mode 100644 index 0000000..17e5f95 --- /dev/null +++ b/server/src/task/job_registry.ts @@ -0,0 +1,59 @@ +import { demoHeartbeatJob } from "./demo_heartbeat.task"; +import { logCleanJob } from "./log_clean.task"; + +export type JobParamType = "number" | "string"; + +/** 可编辑参数白名单字段(前端表单由它驱动渲染) */ +export interface JobParamField { + key: string; + label: string; + type: JobParamType; + min?: number; // type=number 时的最小值 + max?: number; // type=number 时的最大值 + placeholder?: string; +} + +export interface JobLogger { + info(message: string, ...args: any[]): void; + warn(message: string, ...args: any[]): void; + error(message: string, ...args: any[]): void; +} + +/** 调度器注入给任务的运行时能力(避免任务模块直接依赖 DI 与 Sequelize) */ +export interface JobRuntime { + logger: JobLogger; + /** 清理 sys_operation_log 中早于 keepDays 天的记录,返回删除条数 */ + cleanOperationLogs(keepDays: number): Promise; + /** 清理 sys_job_log 中早于 keepDays 天的记录,返回删除条数 */ + cleanJobLogs(keepDays: number): Promise; +} + +export interface JobHandlerContext { + jobKey: string; + jobName: string; + triggerType: number; // 1定时自动 2手动触发 3失败重试 + attempt: number; // 第几次尝试,从 1 开始 + runtime: JobRuntime; +} + +/** 返回值作为本次执行的结果摘要(message);抛异常视为执行失败 */ +export type JobHandler = ( + params: Record, + ctx: JobHandlerContext +) => Promise | string | void; + +export interface BuiltinJob { + jobKey: string; + jobName: string; + description: string; + defaultCron: string; + defaultParams: Record; + paramFields: JobParamField[]; + handler: JobHandler; +} + +export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob]; + +export function getBuiltinJob(jobKey: string): BuiltinJob | undefined { + return BUILTIN_JOBS.find(job => job.jobKey === jobKey); +} diff --git a/server/src/task/log_clean.task.ts b/server/src/task/log_clean.task.ts new file mode 100644 index 0000000..88d8379 --- /dev/null +++ b/server/src/task/log_clean.task.ts @@ -0,0 +1,22 @@ +import type { BuiltinJob } from "./job_registry"; + +/** + * 日志清理任务:清理早于 keepDays 天的操作日志与任务日志。 + * 默认保留 90 天,每日 03:00 执行。 + */ +export const logCleanJob: BuiltinJob = { + jobKey: "log_clean", + jobName: "日志清理", + description: "清理早于 keepDays 天的操作日志与任务日志", + defaultCron: "0 3 * * *", + defaultParams: { keepDays: 90 }, + paramFields: [ + { key: "keepDays", label: "保留天数", type: "number", min: 1, max: 3650 } + ], + handler: async (params, ctx) => { + const keepDays = Number(params?.keepDays ?? 90); + const opCount = await ctx.runtime.cleanOperationLogs(keepDays); + const jobCount = await ctx.runtime.cleanJobLogs(keepDays); + return `清理操作日志 ${opCount} 条、任务日志 ${jobCount} 条`; + } +}; diff --git a/server/src/util/token.ts b/server/src/util/token.ts index 8a74184..c8c555b 100644 --- a/server/src/util/token.ts +++ b/server/src/util/token.ts @@ -38,6 +38,40 @@ export class TokenUtil { } } + // 生成电工 APP 令牌:携带 userType=worker 以区分管理端 token + generateWorkerToken(payload: { workerId: number; phone: string }): string { + return jwt.sign({ ...payload, userType: "worker" }, this.jwtConfig.secret, { + expiresIn: this.jwtConfig.expiresIn as jwt.SignOptions["expiresIn"] + }); + } + + // 校验电工 APP 令牌:除有效期/签名外,还需 userType 为 worker + verifyWorkerToken(token: string): any { + let decoded: any; + try { + decoded = jwt.verify(token, this.jwtConfig.secret); + } catch (error) { + if (error instanceof TokenExpiredError) { + error.name = "TokenExpiredError"; + throw error; + } + if (error instanceof JsonWebTokenError) { + error.name = "JsonWebTokenError"; + throw error; + } + throw new Error("无效的认证令牌"); + } + + // 防止管理端令牌被用于电工 APP 接口 + if (!decoded || decoded.userType !== "worker") { + const invalidError = new JsonWebTokenError("无效的认证令牌"); + invalidError.name = "JsonWebTokenError"; + throw invalidError; + } + + return decoded; + } + decodeToken(token: string): any { return jwt.decode(token); } diff --git a/server/yarn.lock b/server/yarn.lock index 0886053..195f867 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -247,7 +247,7 @@ resolved "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz" integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg== -"@babel/core@^7.0.0", "@babel/core@^7.0.0 || ^8.0.0-0", "@babel/core@^7.0.0-0", "@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.23.9", "@babel/core@^7.8.0", "@babel/core@>=7.0.0-beta.0 <8": +"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.23.9": version "7.29.7" resolved "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz" integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA== @@ -530,7 +530,7 @@ dependencies: "@hapi/hoek" "^9.0.0" -"@ioredis/commands@^1.0.2", "@ioredis/commands@1.10.0": +"@ioredis/commands@1.10.0", "@ioredis/commands@^1.0.2": version "1.10.0" resolved "https://registry.npmmirror.com/@ioredis/commands/-/commands-1.10.0.tgz" integrity sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q== @@ -710,7 +710,7 @@ jest-haste-map "^29.7.0" slash "^3.0.0" -"@jest/transform@^29.0.0 || ^30.0.0", "@jest/transform@^29.7.0": +"@jest/transform@^29.7.0": version "29.7.0" resolved "https://registry.npmmirror.com/@jest/transform/-/transform-29.7.0.tgz" integrity sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw== @@ -731,7 +731,7 @@ slash "^3.0.0" write-file-atomic "^4.0.2" -"@jest/types@^29.0.0 || ^30.0.0", "@jest/types@^29.6.3": +"@jest/types@^29.6.3": version "29.6.3" resolved "https://registry.npmmirror.com/@jest/types/-/types-29.6.3.tgz" integrity sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw== @@ -769,14 +769,6 @@ resolved "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz" integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== -"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28": - version "0.3.31" - resolved "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz" - integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== - dependencies: - "@jridgewell/resolve-uri" "^3.1.0" - "@jridgewell/sourcemap-codec" "^1.4.14" - "@jridgewell/trace-mapping@0.3.9": version "0.3.9" resolved "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz" @@ -785,6 +777,14 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" +"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28": + version "0.3.31" + resolved "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + "@koa/router@^12.0.0": version "12.0.2" resolved "https://registry.npmmirror.com/@koa/router/-/router-12.0.2.tgz" @@ -918,6 +918,13 @@ picomatch "2.3.1" reflect-metadata "0.2.2" +"@midwayjs/cron@^3.19.0": + version "3.20.24" + resolved "https://registry.npmmirror.com/@midwayjs/cron/-/cron-3.20.24.tgz#708c5f49ffaf99a0f09e3ad4268edfdc23b207cb" + integrity sha512-L4xTTxTUNLujilJnscA7DWSSz1UeA7snjVnTI11jAXYiD7lJPSOVh0mV6yH/VA+ZtLbVqWCd0mrvNwjS4tpizQ== + dependencies: + cron "3.5.0" + "@midwayjs/debugger@^1.0.x": version "1.0.9" resolved "https://registry.npmmirror.com/@midwayjs/debugger/-/debugger-1.0.9.tgz" @@ -982,7 +989,7 @@ semver "^7.3.4" supports-color "^8.1.0" -"@midwayjs/mock@*", "@midwayjs/mock@^3.19.0": +"@midwayjs/mock@^3.19.0": version "3.20.24" resolved "https://registry.npmmirror.com/@midwayjs/mock/-/mock-3.20.24.tgz" integrity sha512-44DODB1uuVsXZyqVZtTTJboH5LttvJM0Dn/Xlg0SmyBDrKmA7m5SYUTqEn/knb/UabgkfOjJnhdjXlkEil0KAA== @@ -1049,7 +1056,7 @@ "@nodelib/fs.stat" "2.0.5" run-parallel "^1.1.9" -"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5": +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": version "2.0.5" resolved "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== @@ -1399,6 +1406,11 @@ "@types/koa-compose" "*" "@types/node" "*" +"@types/luxon@~3.4.0": + version "3.4.2" + resolved "https://registry.npmmirror.com/@types/luxon/-/luxon-3.4.2.tgz#e4fc7214a420173cea47739c33cdf10874694db7" + integrity sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA== + "@types/methods@^1.1.4": version "1.1.4" resolved "https://registry.npmmirror.com/@types/methods/-/methods-1.1.4.tgz" @@ -1488,7 +1500,7 @@ resolved "https://registry.npmmirror.com/@types/uuid/-/uuid-10.0.0.tgz" integrity sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ== -"@types/validator@*", "@types/validator@^13.15.10", "@types/validator@^13.15.3", "@types/validator@^13.7.17": +"@types/validator@^13.15.10", "@types/validator@^13.15.3", "@types/validator@^13.7.17": version "13.15.10" resolved "https://registry.npmmirror.com/@types/validator/-/validator-13.15.10.tgz" integrity sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA== @@ -1616,7 +1628,7 @@ asynckit@^0.4.0: resolved "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== -"babel-jest@^29.0.0 || ^30.0.0", babel-jest@^29.7.0: +babel-jest@^29.7.0: version "29.7.0" resolved "https://registry.npmmirror.com/babel-jest/-/babel-jest-29.7.0.tgz" integrity sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg== @@ -1743,7 +1755,7 @@ braces@^3.0.3, braces@~3.0.2: dependencies: fill-range "^7.1.1" -browserslist@^4.24.0, "browserslist@>= 4.21.0": +browserslist@^4.24.0: version "4.28.2" resolved "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz" integrity sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg== @@ -1786,7 +1798,7 @@ buffer@^6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" -bytes@~3.1.2, bytes@3.1.2: +bytes@3.1.2, bytes@~3.1.2: version "3.1.2" resolved "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== @@ -1885,7 +1897,7 @@ cjs-module-lexer@^1.0.0: resolved "https://registry.npmmirror.com/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz" integrity sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q== -class-transformer@^0.5.1, class-transformer@0.5.1: +class-transformer@0.5.1, class-transformer@^0.5.1: version "0.5.1" resolved "https://registry.npmmirror.com/class-transformer/-/class-transformer-0.5.1.tgz" integrity sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw== @@ -1908,16 +1920,16 @@ cliui@^8.0.1: strip-ansi "^6.0.1" wrap-ansi "^7.0.0" -cluster-key-slot@^1.1.0: - version "1.1.2" - resolved "https://registry.npmmirror.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz" - integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA== - cluster-key-slot@1.1.1: version "1.1.1" resolved "https://registry.npmmirror.com/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz" integrity sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw== +cluster-key-slot@^1.1.0: + version "1.1.2" + resolved "https://registry.npmmirror.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz" + integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA== + co-body@^6.0.0: version "6.2.0" resolved "https://registry.npmmirror.com/co-body/-/co-body-6.2.0.tgz" @@ -2026,6 +2038,14 @@ create-require@^1.1.0: resolved "https://registry.npmmirror.com/create-require/-/create-require-1.1.1.tgz" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== +cron@3.5.0: + version "3.5.0" + resolved "https://registry.npmmirror.com/cron/-/cron-3.5.0.tgz#e8caa384e998ed275e19598df4dbd0e8578c35d9" + integrity sha512-0eYZqCnapmxYcV06uktql93wNWdlTmmBFP2iYz+JPVcQqlyFYcn1lFuIk4R54pkOmE7mcldTAPZv6X5XA4Q46A== + dependencies: + "@types/luxon" "~3.4.0" + luxon "~3.5.0" + cross-env@^10.1.0: version "10.1.0" resolved "https://registry.npmmirror.com/cross-env/-/cross-env-10.1.0.tgz" @@ -2053,7 +2073,7 @@ dayjs@^1.11.13: resolved "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz" integrity sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA== -debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@4, debug@4.4.3: +debug@4, debug@4.4.3, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: version "4.4.3" resolved "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -2085,17 +2105,17 @@ delegates@^1.0.0: resolved "https://registry.npmmirror.com/delegates/-/delegates-1.0.0.tgz" integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ== -denque@^1.1.0: - version "1.5.1" - resolved "https://registry.npmmirror.com/denque/-/denque-1.5.1.tgz" - integrity sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw== - denque@2.1.0: version "2.1.0" resolved "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz" integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== -depd@^2.0.0, depd@~2.0.0, depd@2.0.0: +denque@^1.1.0: + version "1.5.1" + resolved "https://registry.npmmirror.com/denque/-/denque-1.5.1.tgz" + integrity sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw== + +depd@2.0.0, depd@^2.0.0, depd@~2.0.0: version "2.0.0" resolved "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== @@ -2320,7 +2340,7 @@ fast-glob@^3.0.3: merge2 "^1.3.0" micromatch "^4.0.8" -fast-json-stable-stringify@^2.1.0, fast-json-stable-stringify@2.x: +fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.1.0: version "2.1.0" resolved "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== @@ -2420,6 +2440,11 @@ fs.realpath@^1.0.0: resolved "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== +fsevents@^2.3.2, fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + function-bind@^1.1.2: version "1.1.2" resolved "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz" @@ -2481,18 +2506,6 @@ glob-parent@^5.1.2, glob-parent@~5.1.2: dependencies: is-glob "^4.0.1" -glob@^7.1.3, glob@^7.1.4: - version "7.2.3" - resolved "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz" - integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.1.1" - once "^1.3.0" - path-is-absolute "^1.0.0" - glob@7.2.0: version "7.2.0" resolved "https://registry.npmmirror.com/glob/-/glob-7.2.0.tgz" @@ -2505,6 +2518,18 @@ glob@7.2.0: once "^1.3.0" path-is-absolute "^1.0.0" +glob@^7.1.3, glob@^7.1.4: + version "7.2.3" + resolved "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + globby@^10.0.1: version "10.0.2" resolved "https://registry.npmmirror.com/globby/-/globby-10.0.2.tgz" @@ -2578,39 +2603,6 @@ http-assert@^1.3.0: deep-equal "~1.0.1" http-errors "~1.8.0" -http-errors@^1.6.3, http-errors@~1.8.0: - version "1.8.1" - resolved "https://registry.npmmirror.com/http-errors/-/http-errors-1.8.1.tgz" - integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g== - dependencies: - depd "~1.1.2" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses ">= 1.5.0 < 2" - toidentifier "1.0.1" - -http-errors@^2.0.0: - version "2.0.1" - resolved "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz" - integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== - dependencies: - depd "~2.0.0" - inherits "~2.0.4" - setprototypeof "~1.2.0" - statuses "~2.0.2" - toidentifier "~1.0.1" - -http-errors@~2.0.1: - version "2.0.1" - resolved "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz" - integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== - dependencies: - depd "~2.0.0" - inherits "~2.0.4" - setprototypeof "~1.2.0" - statuses "~2.0.2" - toidentifier "~1.0.1" - http-errors@2.0.0: version "2.0.0" resolved "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.0.tgz" @@ -2622,12 +2614,34 @@ http-errors@2.0.0: statuses "2.0.1" toidentifier "1.0.1" +http-errors@^1.6.3, http-errors@~1.8.0: + version "1.8.1" + resolved "https://registry.npmmirror.com/http-errors/-/http-errors-1.8.1.tgz" + integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g== + dependencies: + depd "~1.1.2" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses ">= 1.5.0 < 2" + toidentifier "1.0.1" + +http-errors@^2.0.0, http-errors@~2.0.1: + version "2.0.1" + resolved "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz" + integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== + dependencies: + depd "~2.0.0" + inherits "~2.0.4" + setprototypeof "~1.2.0" + statuses "~2.0.2" + toidentifier "~1.0.1" + human-signals@^2.1.0: version "2.1.0" resolved "https://registry.npmmirror.com/human-signals/-/human-signals-2.1.0.tgz" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== -iconv-lite@~0.4.24, iconv-lite@0.4.24: +iconv-lite@0.4.24, iconv-lite@~0.4.24: version "0.4.24" resolved "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -2675,7 +2689,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@~2.0.3, inherits@~2.0.4, inherits@2, inherits@2.0.4: +inherits@2, inherits@2.0.4, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -3058,7 +3072,7 @@ jest-resolve-dependencies@^29.7.0: jest-regex-util "^29.6.3" jest-snapshot "^29.7.0" -jest-resolve@*, jest-resolve@^29.7.0: +jest-resolve@^29.7.0: version "29.7.0" resolved "https://registry.npmmirror.com/jest-resolve/-/jest-resolve-29.7.0.tgz" integrity sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA== @@ -3154,7 +3168,7 @@ jest-snapshot@^29.7.0: pretty-format "^29.7.0" semver "^7.5.3" -"jest-util@^29.0.0 || ^30.0.0", jest-util@^29.7.0: +jest-util@^29.7.0: version "29.7.0" resolved "https://registry.npmmirror.com/jest-util/-/jest-util-29.7.0.tgz" integrity sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA== @@ -3202,7 +3216,7 @@ jest-worker@^29.7.0: merge-stream "^2.0.0" supports-color "^8.0.0" -"jest@^29.0.0 || ^30.0.0", jest@^29.7.0: +jest@^29.7.0: version "29.7.0" resolved "https://registry.npmmirror.com/jest/-/jest-29.7.0.tgz" integrity sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw== @@ -3228,6 +3242,13 @@ js-tokens@^4.0.0: resolved "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== +js-yaml@4.1.0: + version "4.1.0" + resolved "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + js-yaml@^3.13.1: version "3.14.2" resolved "https://registry.npmmirror.com/js-yaml/-/js-yaml-3.14.2.tgz" @@ -3243,13 +3264,6 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" -js-yaml@4.1.0: - version "4.1.0" - resolved "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== - dependencies: - argparse "^2.0.1" - jsesc@^3.0.2: version "3.1.0" resolved "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz" @@ -3346,7 +3360,7 @@ koa-convert@^2.0.0: co "^4.6.0" koa-compose "^4.1.0" -koa@^2.11.0, koa@2.16.4: +koa@2.16.4, koa@^2.11.0: version "2.16.4" resolved "https://registry.npmmirror.com/koa/-/koa-2.16.4.tgz" integrity sha512-3An0GCLDSR34tsCO4H8Tef8Pp2ngtaZDAZnsWJYelqXUK5wyiHvGItgK/xcSkmHLSTn1Jcho1mRQs2ehRzvKKw== @@ -3467,6 +3481,11 @@ lodash@^4.17.21: resolved "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz" integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== +lru-cache@7.18.3: + version "7.18.3" + resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-7.18.3.tgz" + integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz" @@ -3474,10 +3493,10 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" -lru-cache@7.18.3: - version "7.18.3" - resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-7.18.3.tgz" - integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== +luxon@~3.5.0: + version "3.5.0" + resolved "https://registry.npmmirror.com/luxon/-/luxon-3.5.0.tgz#6b6f65c5cd1d61d1fd19dbf07ee87a50bf4b8e20" + integrity sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ== make-dir@^4.0.0: version "4.0.0" @@ -3824,7 +3843,7 @@ pg-types@2.2.0: postgres-date "~1.0.4" postgres-interval "^1.1.0" -pg@^8.11.0, pg@>=8.0: +pg@^8.11.0: version "8.23.0" resolved "https://registry.npmmirror.com/pg/-/pg-8.23.0.tgz" integrity sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg== @@ -3849,16 +3868,16 @@ picocolors@^1.1.1: resolved "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== -picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: - version "2.3.2" - resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz" - integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== - picomatch@2.3.1: version "2.3.1" resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: + version "2.3.2" + resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz" + integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== + pirates@^4.0.4: version "4.0.7" resolved "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz" @@ -3920,13 +3939,6 @@ pure-rand@^6.0.0: resolved "https://registry.npmmirror.com/pure-rand/-/pure-rand-6.1.0.tgz" integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA== -qs@^6.11.0, qs@^6.5.2: - version "6.15.2" - resolved "https://registry.npmmirror.com/qs/-/qs-6.15.2.tgz" - integrity sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw== - dependencies: - side-channel "^1.1.0" - qs@6.14.0: version "6.14.0" resolved "https://registry.npmmirror.com/qs/-/qs-6.14.0.tgz" @@ -3934,6 +3946,13 @@ qs@6.14.0: dependencies: side-channel "^1.1.0" +qs@^6.11.0, qs@^6.5.2: + version "6.15.2" + resolved "https://registry.npmmirror.com/qs/-/qs-6.15.2.tgz" + integrity sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw== + dependencies: + side-channel "^1.1.0" + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz" @@ -3946,16 +3965,6 @@ queue@^6.0.0: dependencies: inherits "~2.0.3" -raw-body@^2.3.3: - version "2.5.3" - resolved "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.3.tgz" - integrity sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA== - dependencies: - bytes "~3.1.2" - http-errors "~2.0.1" - iconv-lite "~0.4.24" - unpipe "~1.0.0" - raw-body@2.5.2: version "2.5.2" resolved "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.2.tgz" @@ -3966,6 +3975,16 @@ raw-body@2.5.2: iconv-lite "0.4.24" unpipe "1.0.0" +raw-body@^2.3.3: + version "2.5.3" + resolved "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.3.tgz" + integrity sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA== + dependencies: + bytes "~3.1.2" + http-errors "~2.0.1" + iconv-lite "~0.4.24" + unpipe "~1.0.0" + react-is@^18.0.0: version "18.3.1" resolved "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz" @@ -3996,19 +4015,19 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" -redis-errors@^1.0.0, redis-errors@^1.2.0, redis-errors@1.2.0: +redis-errors@1.2.0, redis-errors@^1.0.0, redis-errors@^1.2.0: version "1.2.0" resolved "https://registry.npmmirror.com/redis-errors/-/redis-errors-1.2.0.tgz" integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w== -redis-parser@^3.0.0, redis-parser@3.0.0: +redis-parser@3.0.0, redis-parser@^3.0.0: version "3.0.0" resolved "https://registry.npmmirror.com/redis-parser/-/redis-parser-3.0.0.tgz" integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A== dependencies: redis-errors "^1.0.0" -reflect-metadata@*, reflect-metadata@^0.2.2, reflect-metadata@0.2.2: +reflect-metadata@0.2.2, reflect-metadata@^0.2.2: version "0.2.2" resolved "https://registry.npmmirror.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz" integrity sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q== @@ -4062,7 +4081,7 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -safe-buffer@^5.0.1, safe-buffer@~5.2.0, safe-buffer@5.2.1: +safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -4091,12 +4110,7 @@ scmp@^2.1.0: resolved "https://registry.npmmirror.com/scmp/-/scmp-2.1.0.tgz" integrity sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q== -semver@^6.3.0: - version "6.3.1" - resolved "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz" - integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== - -semver@^6.3.1: +semver@^6.3.0, semver@^6.3.1: version "6.3.1" resolved "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== @@ -4118,7 +4132,7 @@ sequelize-typescript@^2.1.6: dependencies: glob "7.2.0" -sequelize@^6.37.5, sequelize@>=6.20.1: +sequelize@^6.37.5: version "6.37.8" resolved "https://registry.npmmirror.com/sequelize/-/sequelize-6.37.8.tgz" integrity sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw== @@ -4140,7 +4154,7 @@ sequelize@^6.37.5, sequelize@>=6.20.1: validator "^13.9.0" wkx "^0.5.0" -setprototypeof@~1.2.0, setprototypeof@1.2.0: +setprototypeof@1.2.0, setprototypeof@~1.2.0: version "1.2.0" resolved "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz" integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== @@ -4217,14 +4231,6 @@ slash@^3.0.0: resolved "https://registry.npmmirror.com/slash/-/slash-3.0.0.tgz" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== -source-map-support@^0.5.19: - version "0.5.21" - resolved "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz" - integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== - dependencies: - buffer-from "^1.0.0" - source-map "^0.6.0" - source-map-support@0.5.13: version "0.5.13" resolved "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.13.tgz" @@ -4233,6 +4239,14 @@ source-map-support@0.5.13: buffer-from "^1.0.0" source-map "^0.6.0" +source-map-support@^0.5.19: + version "0.5.21" + resolved "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz" + integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + source-map@^0.6.0, source-map@^0.6.1: version "0.6.1" resolved "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz" @@ -4255,12 +4269,17 @@ stack-utils@^2.0.3: dependencies: escape-string-regexp "^2.0.0" -standard-as-callback@^2.1.0, standard-as-callback@2.1.0: +standard-as-callback@2.1.0, standard-as-callback@^2.1.0: version "2.1.0" resolved "https://registry.npmmirror.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz" integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A== -statuses@^1.5.0, "statuses@>= 1.5.0 < 2": +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.npmmirror.com/statuses/-/statuses-2.0.1.tgz" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + +"statuses@>= 1.5.0 < 2", statuses@^1.5.0: version "1.5.0" resolved "https://registry.npmmirror.com/statuses/-/statuses-1.5.0.tgz" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== @@ -4270,18 +4289,6 @@ statuses@~2.0.2: resolved "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz" integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== -statuses@2.0.1: - version "2.0.1" - resolved "https://registry.npmmirror.com/statuses/-/statuses-2.0.1.tgz" - integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== - -string_decoder@^1.3.0: - version "1.3.0" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - string-length@^4.0.1: version "4.0.2" resolved "https://registry.npmmirror.com/string-length/-/string-length-4.0.2.tgz" @@ -4299,6 +4306,13 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" +string_decoder@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz" @@ -4365,14 +4379,7 @@ supports-color@^7.1.0: dependencies: has-flag "^4.0.0" -supports-color@^8.0.0: - version "8.1.1" - resolved "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz" - integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - dependencies: - has-flag "^4.0.0" - -supports-color@^8.1.0: +supports-color@^8.0.0, supports-color@^8.1.0: version "8.1.1" resolved "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz" integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== @@ -4405,7 +4412,7 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" -toidentifier@~1.0.1, toidentifier@1.0.1: +toidentifier@1.0.1, toidentifier@~1.0.1: version "1.0.1" resolved "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== @@ -4443,7 +4450,7 @@ ts-jest@^29.2.5: type-fest "^4.41.0" yargs-parser "^21.1.1" -ts-node@^10.0.0, ts-node@>=9.0.0: +ts-node@^10.0.0: version "10.9.2" resolved "https://registry.npmmirror.com/ts-node/-/ts-node-10.9.2.tgz" integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== @@ -4510,7 +4517,7 @@ typescript@^4.1.0: resolved "https://registry.npmmirror.com/typescript/-/typescript-4.9.5.tgz" integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== -typescript@^5.7.0, typescript@>=2.7, "typescript@>=4.3 <7": +typescript@^5.7.0: version "5.9.3" resolved "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== @@ -4525,11 +4532,6 @@ underscore@^1.13.1: resolved "https://registry.npmmirror.com/underscore/-/underscore-1.13.8.tgz" integrity sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ== -"undici-types@>=7.24.0 <7.24.7": - version "7.24.6" - resolved "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz" - integrity sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg== - undici-types@~6.21.0: version "6.21.0" resolved "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz" @@ -4540,7 +4542,7 @@ universalify@^0.1.0: resolved "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz" integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== -unpipe@~1.0.0, unpipe@1.0.0: +unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz" integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==