import { Provide } from "@midwayjs/core"; import { InjectRepository } from "@midwayjs/sequelize"; import { Repository } from "sequelize-typescript"; import { Op } from "sequelize"; import dayjs from "dayjs"; import SysNotice from "../entity/sys_notice.entity"; import WorkerCert from "../entity/worker_cert.entity"; import Worker from "../entity/worker.entity"; import SysUser from "../entity/sysUser.entity"; import SysUserRole from "../entity/sysUserRole.entity"; import SysRole from "../entity/sysRole.entity"; // 通知类型:证照到期提醒 const NOTICE_TYPE_CERT_EXPIRE = "cert_expire"; // 业务类型:电工资质证照 const NOTICE_BIZ_TYPE_WORKER_CERT = "worker_cert"; // 到期提醒提前天数 const CERT_EXPIRE_WARN_DAYS = 30; // 超级管理员角色编码(拥有全部权限,作为证照提醒接收人) const ADMIN_ROLE_CODE = "ADMIN"; // 分页上限,防止单次拉取过多数据 const MAX_PAGE_SIZE = 200; // 归一化 DATEONLY 值为 YYYY-MM-DD 字符串(NULL/空 表示长期有效) function toDateOnly(value: any): string { if (value === null || value === undefined || value === "") return ""; const str = String(value); return str.length >= 10 ? str.slice(0, 10) : str; } @Provide() export class NoticeService { @InjectRepository(SysNotice) noticeModel!: Repository; @InjectRepository(WorkerCert) certModel!: Repository; @InjectRepository(Worker) workerModel!: Repository; @InjectRepository(SysUser) userModel!: Repository; @InjectRepository(SysUserRole) userRoleModel!: Repository; @InjectRepository(SysRole) roleModel!: Repository; // 我的通知列表:未读优先,可选按已读状态筛选 async listMy(userId: number, query: { page?: any; pageSize?: any; isRead?: any }): Promise { const page = Math.max(Number(query.page) || 1, 1); const rawPageSize = Number(query.pageSize) || 10; const pageSize = Math.min(Math.max(rawPageSize, 1), MAX_PAGE_SIZE); const where: any = { receiverId: userId }; if (query.isRead !== undefined && query.isRead !== "") { where.isRead = Number(query.isRead); } const { count, rows } = await this.noticeModel.findAndCountAll({ where, limit: pageSize, offset: (page - 1) * pageSize, // 未读在前,其次按时间倒序 order: [["is_read", "ASC"], ["id", "DESC"]] }); return { code: 200, message: "获取通知列表成功", data: { list: rows.map((r) => r.toJSON()), total: count, page, pageSize } }; } // 未读数量(顶栏红点) async unreadCount(userId: number): Promise { const count = await this.noticeModel.count({ where: { receiverId: userId, isRead: 0 } }); return { code: 200, message: "获取未读通知数成功", data: { count } }; } // 单条标记已读(仅能操作自己的通知) async markRead(userId: number, id: number): Promise { const notice = await this.noticeModel.findOne({ where: { id, receiverId: userId } }); if (!notice) { return { code: 404, message: "通知不存在", data: null }; } if (Number(notice.isRead) !== 1) { await notice.update({ isRead: 1 }); } return { code: 200, message: "标记已读成功", data: null }; } // 全部标记已读 async markAllRead(userId: number): Promise { const updated = await this.noticeModel.update({ isRead: 1 }, { where: { receiverId: userId, isRead: 0 } }); return { code: 200, message: "全部标记已读成功", data: { updated: Array.isArray(updated) ? updated[0] : 0 } }; } // 查询拥有 ADMIN 角色的所有用户ID(sys_user_role + sys_role 关联,并校验用户真实存在) async getAdminUserIds(): Promise { const adminRole = await this.roleModel.findOne({ where: { roleCode: ADMIN_ROLE_CODE }, attributes: ["id"] }); if (!adminRole) return []; const userRoles = await this.userRoleModel.findAll({ where: { roleId: Number(adminRole.id) }, attributes: ["userId"] }); const userIds = Array.from(new Set(userRoles.map((ur) => Number(ur.userId)))); if (userIds.length === 0) return []; const users = await this.userModel.findAll({ where: { id: { [Op.in]: userIds } }, attributes: ["id"] }); return users.map((u) => Number(u.id)); } // 扫描 30 天内到期或已过期的电工资质证照,向所有 ADMIN 推送站内通知(幂等) async scanCertExpireAndNotify(): Promise<{ scanned: number; created: number }> { let scanned = 0; let created = 0; try { // 使用本地日期字符串比较,避免时区偏差 const today = dayjs().format("YYYY-MM-DD"); const deadline = dayjs().add(CERT_EXPIRE_WARN_DAYS, "day").format("YYYY-MM-DD"); // 1. 查出已设置有效期且在提醒窗口内的证书(含已过期) const certs = await this.certModel.findAll({ where: { expireDate: { [Op.not]: null, [Op.lte]: deadline } }, order: [["expire_date", "ASC"]] }); scanned = certs.length; if (scanned === 0) return { scanned, created }; // 2. worker_cert 未声明 BelongsTo(Worker) 关联,单独查询电工姓名后按 Map 关联,避免 N+1 const workerIds = Array.from(new Set(certs.map((c) => Number(c.workerId)))); const workers = await this.workerModel.findAll({ where: { id: { [Op.in]: workerIds } }, attributes: ["id", "workerName"] }); const workerNameMap = new Map(); for (const w of workers) workerNameMap.set(Number(w.id), w.workerName); // 3. 接收人:全部 ADMIN const adminIds = await this.getAdminUserIds(); if (adminIds.length === 0) return { scanned, created }; // 4. 幂等:按 (noticeType, bizType, bizId, receiverId) 先批量查出已存在的通知,已存在则跳过 const certIds = certs.map((c) => Number(c.id)); const existing = await this.noticeModel.findAll({ where: { noticeType: NOTICE_TYPE_CERT_EXPIRE, bizType: NOTICE_BIZ_TYPE_WORKER_CERT, bizId: { [Op.in]: certIds }, receiverId: { [Op.in]: adminIds } }, attributes: ["bizId", "receiverId"] }); const existsSet = new Set(existing.map((n) => `${Number(n.bizId)}_${Number(n.receiverId)}`)); // 5. 证书 × ADMIN 逐条生成通知 for (const cert of certs) { const expire = toDateOnly(cert.expireDate); if (!expire) continue; const days = dayjs(expire).startOf("day").diff(dayjs(today).startOf("day"), "day"); const workerName = workerNameMap.get(Number(cert.workerId)) || "未知电工"; const certType = cert.certType || "资质证书"; const title = days < 0 ? `电工【${workerName}】的${certType}已于 ${expire} 过期` : `电工【${workerName}】的${certType}将于 ${expire} 到期(剩余 ${days} 天)`; const content = [ `证书编号:${cert.certNo || "—"}`, `发证机关:${cert.issueAuthority || "—"}`, `有效期至:${expire}`, days < 0 ? `已过期 ${Math.abs(days)} 天,请及时提醒电工办理续期` : `剩余 ${days} 天,请及时提醒电工办理续期` ].join(";"); for (const adminId of adminIds) { if (existsSet.has(`${Number(cert.id)}_${adminId}`)) continue; await this.noticeModel.create({ noticeType: NOTICE_TYPE_CERT_EXPIRE, title, content, bizType: NOTICE_BIZ_TYPE_WORKER_CERT, bizId: Number(cert.id), receiverId: adminId, isRead: 0 } as any); existsSet.add(`${Number(cert.id)}_${adminId}`); created += 1; } } return { scanned, created }; } catch (err: any) { // 定时任务不允许抛出,失败仅记录日志 console.error("[notice] 证照到期扫描失败:", err?.message || err); return { scanned, created }; } } // 清理指定证书的到期通知(证书续期/删除后调用,允许重新提醒) async clearByCertIds(certIds: number[]): Promise { if (!certIds || certIds.length === 0) return 0; return await this.noticeModel.destroy({ where: { bizType: NOTICE_BIZ_TYPE_WORKER_CERT, bizId: { [Op.in]: certIds } } }); } }