// 任务归档领域逻辑 (T-ARC) // 自动归档: 顶层任务完成后, 经过项目 autoArchive 档位即到期; 到期时把该任务 // 及其全部子任务作为一个整体归档。子任务不单独判定, 完全跟随父任务。 // 执行双保险: 项目任务加载时惰性执行 (runAutoArchiveForProject) + 每小时 cron 兜底。 import { prisma } from '../db.js' export const AUTO_ARCHIVE_OPTIONS = ['3d', '1w', '1m', 'never'] const DAY_MS = 24 * 3600 * 1000 const SHANGHAI_OFFSET_MS = 8 * 3600 * 1000 /** 按上海日历月加 n 个月 (保留时刻), 用于 1m 档 */ function addMonthsShanghai(date, n) { const shifted = new Date(date.getTime() + SHANGHAI_OFFSET_MS) shifted.setUTCMonth(shifted.getUTCMonth() + n) return new Date(shifted.getTime() - SHANGHAI_OFFSET_MS) } /** 自动归档到期时刻: 完成时刻 + 档位; 'never' 或未完成 -> null */ export function autoArchiveDue(autoArchive, doneAt) { if (!autoArchive || autoArchive === 'never' || !doneAt) return null const at = doneAt instanceof Date ? doneAt : new Date(doneAt) if (Number.isNaN(at.getTime())) return null switch (autoArchive) { case '3d': return new Date(at.getTime() + 3 * DAY_MS) case '1w': return new Date(at.getTime() + 7 * DAY_MS) case '1m': return addMonthsShanghai(at, 1) default: return null } } /** * 修复历史数据里父子归档状态不一致: 子任务完全跟随父任务。 * 只支持一级子任务; 返回被修正的条数。 */ export async function normalizeArchiveTree(projectId) { const tasks = await prisma.task.findMany({ where: { projectId }, select: { id: true, parentId: true, archived: true, archivedAt: true, archiveSource: true }, }) const byId = new Map(tasks.map((t) => [t.id, t])) const fixes = [] for (const t of tasks) { if (!t.parentId) continue const parent = byId.get(t.parentId) if (!parent || parent.archived === t.archived) continue fixes.push({ id: t.id, archived: parent.archived, archivedAt: parent.archived ? (parent.archivedAt || new Date()) : null, archiveSource: parent.archived ? (parent.archiveSource || 'manual') : null, }) } if (!fixes.length) return 0 await prisma.$transaction(fixes.map((f) => prisma.task.update({ where: { id: f.id }, data: { archived: f.archived, archivedAt: f.archivedAt, archiveSource: f.archiveSource, }, }))) return fixes.length } /** * 对单个项目执行一轮到期自动归档 (T-ARC-02)。 * 只看顶层任务: 完成 + 档位到期 -> 该任务与全部子任务一起归档。幂等。 * @returns {number} 本次归档条数 */ export async function runAutoArchiveForProject(projectId) { // 先修复历史父子不一致, 保证要么都显示、要么都归档 await normalizeArchiveTree(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 roots = await prisma.task.findMany({ where: { projectId, parentId: null, archived: false, status: 'done', doneAt: { not: null } }, select: { id: true, doneAt: true }, }) const now = new Date() const dueRootIds = [] for (const t of roots) { const due = autoArchiveDue(project.autoArchive, t.doneAt) if (due && due <= now) dueRootIds.push(t.id) } if (!dueRootIds.length) return 0 // 整棵树一起归档: 子任务不单独判定, 避免父任务归档而子任务仍留在看板 const children = await prisma.task.findMany({ where: { parentId: { in: dueRootIds } }, select: { id: true }, }) const ids = [...dueRootIds, ...children.map((c) => c.id)] const r = await prisma.task.updateMany({ where: { id: { in: ids } }, data: { archived: true, archivedAt: now, archiveSource: 'auto' }, }) return r.count } /** 每小时 cron 兜底: 修复所有项目的父子归档一致性, 并执行到期自动归档 */ export async function runAutoArchiveSweep() { const projects = await prisma.project.findMany({ 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 }