Files
smart_grid_iot/server/src/service/notice.service.ts
T
andyesfly f4454ad014 feat: 新增电工APP端与定时任务管理功能
- 新增电工APP独立鉴权中间件与登录注册接口
- 新增定时任务管理模块,支持任务配置、执行日志与手动触发
- 新增运维费定价与电工派单成本价目管理
- 新增电工资质证照管理与到期提醒定时任务
- 完善数据库实体与DTO校验,优化权限控制
2026-10-02 16:08:00 +08:00

251 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }
}
});
}
}