// 任务归档领域逻辑 (T-ARC) // 自动归档判定: 倒计时起点 = 任务"被隐藏的时刻"(与 date.js isVisibleByRetention // 精确同口径推导): 完成日 + 保留窗口后的上海零点; 加上项目 autoArchive 档位即到期时刻。 // 执行双保险: 项目任务加载时惰性执行 (runAutoArchiveForProject) + 每小时 cron 兜底。 import { prisma } from '../db.js' import { toDateStr, addDays, startOfWeek, startOfMonth } from './date.js' export const AUTO_ARCHIVE_OPTIONS = ['3d', '1w', '1m', 'never'] const DAY_MS = 24 * 3600 * 1000 const SHANGHAI_OFFSET_MS = 8 * 3600 * 1000 /** 上海日历日 "YYYY-MM-DD" 的零点时刻 (UTC 表示) */ function shanghaiMidnight(dayStr) { const [y, m, d] = dayStr.split('-').map(Number) return new Date(Date.UTC(y, m - 1, d) - SHANGHAI_OFFSET_MS) } /** 上海日历日 + n 个月后的零点 (月末溢出由 Date 归一化) */ function addMonths(dayStr, n) { const [y, m, d] = dayStr.split('-').map(Number) return new Date(Date.UTC(y, m - 1 + n, d) - SHANGHAI_OFFSET_MS) } /** * 任务被隐藏的时刻: doneAt 所在完成日之后, 保留窗口结束的上海零点。 * 与 date.js isVisibleByRetention 的可见判定精确同口径: * never → doneAt 即刻隐藏; today → 完成日次日零点; 3d → 完成日+3 零点; * week → 完成周次下一周一零点; month → 次月 1 日零点; forever → 永不隐藏 (null) */ export function hiddenAtOf(doneAt, option) { if (!doneAt) return null const day = toDateStr(doneAt) switch (option) { case 'never': return new Date(doneAt) case 'today': return shanghaiMidnight(addDays(day, 1)) case '3d': return shanghaiMidnight(addDays(day, 3)) case 'week': return shanghaiMidnight(addDays(startOfWeek(day), 7)) case 'month': return shanghaiMidnight(String(addDays(day, 32)).slice(0, 8) + '01') default: return null // forever: 永不隐藏 } } /** 自动归档到期时刻: 隐藏时刻 + 档位; 'never' 或无法隐藏 → null */ export function autoArchiveDue(autoArchive, doneAt, columnRetention) { if (!autoArchive || autoArchive === 'never') return null const hidden = hiddenAtOf(doneAt, columnRetention) if (!hidden) return null switch (autoArchive) { case '3d': return new Date(hidden.getTime() + 3 * DAY_MS) case '1w': return new Date(hidden.getTime() + 7 * DAY_MS) case '1m': { const d = new Date(hidden) d.setUTCMonth(d.getUTCMonth() + 1) return d } default: return null } } /** * 对单个项目执行一轮到期自动归档 (T-ARC-02)。 * 只处理 status=done / doneAt 非空 / 未归档 的任务; 幂等。 * @returns {number} 本次归档条数 */ export async function runAutoArchiveForProject(projectId) { const project = await prisma.project.findUnique({ where: { id: projectId }, select: { autoArchive: true }, }) if (!project || !AUTO_ARCHIVE_OPTIONS.includes(project.autoArchive) || project.autoArchive === 'never') return 0 const columns = await prisma.boardColumn.findMany({ where: { projectId }, select: { id: true, retention: true }, }) const retentionById = new Map(columns.map((c) => [c.id, c.retention || 'forever'])) const tasks = await prisma.task.findMany({ where: { projectId, archived: false, status: 'done', doneAt: { not: null } }, select: { id: true, doneAt: true, columnId: true }, }) const now = new Date() const dueIds = [] for (const t of tasks) { const due = autoArchiveDue(project.autoArchive, t.doneAt, retentionById.get(t.columnId)) if (due && due <= now) dueIds.push(t.id) } if (!dueIds.length) return 0 const r = await prisma.task.updateMany({ where: { id: { in: dueIds } }, data: { archived: true, archivedAt: now, archiveSource: 'auto' }, }) return r.count } /** 每小时 cron 兜底: 扫描所有启用了自动归档的项目 */ export async function runAutoArchiveSweep() { const projects = await prisma.project.findMany({ where: { autoArchive: { not: 'never' } }, select: { id: true }, }) let total = 0 for (const p of projects) { try { total += await runAutoArchiveForProject(p.id) } catch (e) { console.error('[archive] 项目自动归档失败:', p.id, e.message) } } return total }