- 新增电工APP独立鉴权中间件与登录注册接口 - 新增定时任务管理模块,支持任务配置、执行日志与手动触发 - 新增运维费定价与电工派单成本价目管理 - 新增电工资质证照管理与到期提醒定时任务 - 完善数据库实体与DTO校验,优化权限控制
251 lines
8.0 KiB
TypeScript
251 lines
8.0 KiB
TypeScript
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 }
|
||
}
|
||
});
|
||
}
|
||
} |