feat: 新增电工APP端与定时任务管理功能

- 新增电工APP独立鉴权中间件与登录注册接口
- 新增定时任务管理模块,支持任务配置、执行日志与手动触发
- 新增运维费定价与电工派单成本价目管理
- 新增电工资质证照管理与到期提醒定时任务
- 完善数据库实体与DTO校验,优化权限控制
This commit is contained in:
andyesfly committed 2026-10-02 16:08:00 +08:00
1 parent 1803ad679c
commit f4454ad014
57 files changed
+9388 -200

No files matched your search

+2
View File
@@ -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",
+18 -3
View File
@@ -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("系统已停止");
}
}
@@ -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);
}
}
+41
View File
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
+31 -2
View File
@@ -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));
}
}
@@ -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;
}
}
+52
View File
@@ -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;
}
+63
View File
@@ -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<string, any>;
@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;
}
+52
View File
@@ -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;
}
+69 -1
View File
@@ -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;
}
+95
View File
@@ -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;
}
+19 -3
View File
@@ -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;
}
+47
View File
@@ -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;
}
+105
View File
@@ -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<string, any>;
@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;
}
+94
View File
@@ -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;
}
+41
View File
@@ -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;
}
+22 -1
View File
@@ -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;
+47
View File
@@ -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;
}
+2 -2
View File
@@ -5,8 +5,8 @@ import { TokenUtil } from '../util/token';
@Provide()
export class AuthMiddleware implements IMiddleware<Context, NextFunction> {
// 中间件匹配范围:全局生效,但跳过公开接口
ignore = ['/api/auth/*', '/api/health'];
// 中间件匹配范围:全局生效,但跳过公开接口与电工 APP 接口(电工 APP 走独立鉴权)
ignore = ['/api/auth/*', '/api/health', '/api/worker-app/*'];
@Inject()
tokenUtil!: TokenUtil;
+15 -2
View File
@@ -13,6 +13,7 @@ const PERMISSION_MAP: Record<string, string> = {
'/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<string, string> = {
'/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<Context, NextFunction> {
// 中间件匹配范围:全局生效,跳过公开接口与仪表盘
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;
@@ -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<Context, NextFunction> {
// 注意: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();
};
}
}
@@ -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<number, string> = { 1: "次", 2: "小时" };
@Provide()
export class DispatchPriceService {
@InjectRepository(DispatchPrice)
dispatchPriceModel!: Repository<DispatchPrice>;
// 价格编码规则:DG + 东八区 yyyymmdd + 3 位序号(当日已有记录数 +1),唯一冲突时递增重试 1 次
private async generatePriceCode(): Promise<string> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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 };
}
}
+231
View File
@@ -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<SysJob>;
@InjectRepository(SysJobLog)
jobLogModel!: Repository<SysJobLog>;
@Inject()
scheduler!: JobSchedulerService;
/** 任务列表:按任务名称模糊 + 状态筛选,附带注册表信息与下次执行时间 */
async list(query: { page?: any; pageSize?: any; jobName?: string; status?: any }): Promise<any> {
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<any> {
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<string, any> =
dto.params && typeof dto.params === "object" ? dto.params : {};
const baseParams: Record<string, any> =
job.params && typeof job.params === "object" ? job.params : {};
const params: Record<string, any> = {};
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<any> {
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<any> {
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<any> {
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<any> {
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 } };
}
}
+429
View File
@@ -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<string, CronJob>();
/** jobKey → 待执行的重试定时器 */
private retryTimers = new Map<string, NodeJS.Timeout>();
/** jobKey 重入锁 */
private running = new Set<string>();
/** 私有日志适配:注册表与任务经它输出日志,沿用仓库内 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<void> {
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<void> {
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<void> {
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<JobRunResult> {
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<number> {
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<JobRunResult> {
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<void> {
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<JobRunResult & { error?: any }> {
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<void> {
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<void> {
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)
};
}
}
+251
View File
@@ -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<SysNotice>;
@InjectRepository(WorkerCert)
certModel!: Repository<WorkerCert>;
@InjectRepository(Worker)
workerModel!: Repository<Worker>;
@InjectRepository(SysUser)
userModel!: Repository<SysUser>;
@InjectRepository(SysUserRole)
userRoleModel!: Repository<SysUserRole>;
@InjectRepository(SysRole)
roleModel!: Repository<SysRole>;
// 我的通知列表:未读优先,可选按已读状态筛选
async listMy(userId: number, query: { page?: any; pageSize?: any; isRead?: any }): Promise<any> {
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<any> {
const count = await this.noticeModel.count({ where: { receiverId: userId, isRead: 0 } });
return {
code: 200,
message: "获取未读通知数成功",
data: { count }
};
}
// 单条标记已读(仅能操作自己的通知)
async markRead(userId: number, id: number): Promise<any> {
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<any> {
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<number[]> {
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<number, string>();
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<number> {
if (!certIds || certIds.length === 0) return 0;
return await this.noticeModel.destroy({
where: {
bizType: NOTICE_BIZ_TYPE_WORKER_CERT,
bizId: { [Op.in]: certIds }
}
});
}
}
+138
View File
@@ -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<number, string> = { 1: "次", 2: "小时", 3: "个" };
@Provide()
export class ServicePriceService {
@InjectRepository(ServicePrice)
servicePriceModel!: Repository<ServicePrice>;
// 价目编码规则:FW + 东八区 yyyymmdd + 3 位序号(当日已有记录数 +1),唯一冲突时递增重试 1 次
private async generatePriceCode(): Promise<string> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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 };
}
}
+396 -9
View File
@@ -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<number, WorkerCert[]> {
const map = new Map<number, WorkerCert[]>();
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<Worker>;
// 列表:姓名/手机号/资质模糊检索 + 状态过滤 + 分页
@InjectRepository(WorkerCert)
certModel!: Repository<WorkerCert>;
@InjectRepository(SysNotice)
noticeModel!: Repository<SysNotice>;
@InjectRepository(SurveyOrder)
surveyOrderModel!: Repository<SurveyOrder>;
@InjectRepository(InstallOrder)
installOrderModel!: Repository<InstallOrder>;
// 资质状态推导所需的日期边界
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<Map<number, WorkerCert[]>> {
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<void> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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 };
}
}
}
+326
View File
@@ -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<Worker>;
@InjectRepository(WorkerCert)
certModel!: Repository<WorkerCert>;
@InjectRepository(SysNotice)
noticeModel!: Repository<SysNotice>;
// 电工注册:手机号唯一校验,初始为待审核(0)且离职(0)
async register(dto: WorkerRegisterDTO): Promise<any> {
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<any> {
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<any> {
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<any> {
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<any> {
const certs = await this.listCertRows(workerId);
return {
code: 200,
message: "获取成功",
data: certs.map(cert => cert.toJSON())
};
}
// 新增资质证照
async createCert(workerId: number, dto: WorkerAppCertDTO): Promise<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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<WorkerCert[]> {
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<void> {
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);
}
}
+19
View File
@@ -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} 条`);
}
}
+24
View File
@@ -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})`;
}
};
+59
View File
@@ -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<number>;
/** 清理 sys_job_log 中早于 keepDays 天的记录,返回删除条数 */
cleanJobLogs(keepDays: number): Promise<number>;
}
export interface JobHandlerContext {
jobKey: string;
jobName: string;
triggerType: number; // 1定时自动 2手动触发 3失败重试
attempt: number; // 第几次尝试,从 1 开始
runtime: JobRuntime;
}
/** 返回值作为本次执行的结果摘要(message);抛异常视为执行失败 */
export type JobHandler = (
params: Record<string, any>,
ctx: JobHandlerContext
) => Promise<string | void> | string | void;
export interface BuiltinJob {
jobKey: string;
jobName: string;
description: string;
defaultCron: string;
defaultParams: Record<string, any>;
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);
}
+22
View File
@@ -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} 条`;
}
};
+34
View File
@@ -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);
}
+170 -168
View File
@@ -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==