/** * 素材归属转移 * * 此前系统里的"转移"是隐式发生的:在编辑素材时改掉作者,`Material.userId` * 被直接覆盖,既不留转移流水,也查不到最初的上传者是谁。而 `MaterialTransfer` * 表和 `Material` 上的 originalOwnerId / transferredAt / transferredBy / transferNote * 四个字段虽然早已建好,却从未被写入过。 * * 本模块把归属变更收敛成一件有据可查的事: * - `MaterialTransfer` 记录每一次转移的流水(谁转给谁、谁操作的、为什么) * - `Material` 上的四个字段保存当前状态(最初属于谁、最近一次何时被谁转移) * * originalOwnerId 只在首次转移时落定,之后不再变动,用于永久保留"最初是谁上传的"。 */ import { prisma } from '@/lib/prisma' /** 转移来源,便于区分是人工单条操作还是离职批量交接 */ export const TRANSFER_REASON = { /** 编辑素材时更换作者 */ EDIT: '编辑素材时更换归属人', /** 账号离职/归档后的批量交接 */ HANDOVER: '账号离职交接', } as const /** * 计算随归属变更一并写入素材的转移字段。 * * @param currentOwnerId 变更前的归属人 * @param existingOriginalOwnerId 素材上已有的最初所有者,为空表示这是第一次转移 */ export function buildMaterialTransferFields(params: { currentOwnerId: string existingOriginalOwnerId?: string | null operatorUserId: string note?: string | null }) { const { currentOwnerId, existingOriginalOwnerId, operatorUserId, note } = params return { // 首次转移时记下最初的上传者,后续转移不再覆盖 originalOwnerId: existingOriginalOwnerId || currentOwnerId, transferredAt: new Date(), transferredBy: operatorUserId, transferNote: note ?? null, } } /** 写入一条转移流水。可传入事务客户端以保证与素材更新的原子性。 */ export async function recordMaterialTransfer( client: { materialTransfer: { create: (args: any) => Promise } }, params: { materialId: string fromUserId: string toUserId: string operatorUserId: string note?: string | null } ): Promise { await client.materialTransfer.create({ data: { materialId: params.materialId, fromUserId: params.fromUserId, toUserId: params.toUserId, transferredBy: params.operatorUserId, transferNote: params.note ?? null, }, }) } export interface BulkTransferResult { /** 转出账号名下的素材总数 */ total: number /** 实际完成转移的条数 */ transferred: number /** 失败条数 */ failed: number /** 是否为预演,预演不写入任何数据 */ dryRun: boolean failures: Array<{ materialId: string; reason: string }> } /** * 把某个账号名下的素材整体转移给另一个账号。 * * 主要面向离职交接:账号归档后无法登录,其名下素材无人可维护,而系统此前 * 只能在编辑页里一条一条改作者。 * * 逐条使用事务,保证「素材归属 + 署名 + 转移流水」三者一致;单条失败不影响 * 其余素材,失败明细一并返回,便于重试与核对。 */ export async function transferMaterialsBetweenUsers(params: { fromUserId: string toUserId: string operatorUserId: string note?: string | null /** 仅转移指定素材类型,不传则转移全部 */ materialTypes?: string[] /** 预演模式:只统计不写入 */ dryRun?: boolean }): Promise { const { fromUserId, toUserId, operatorUserId, note = TRANSFER_REASON.HANDOVER, materialTypes, dryRun = false, } = params if (fromUserId === toUserId) { throw new Error('转出与接收账号不能是同一个人') } const toUser = await prisma.user.findUnique({ where: { id: toUserId }, select: { id: true, username: true, isActive: true, archivedAt: true }, }) if (!toUser) throw new Error('接收账号不存在') if (toUser.archivedAt || !toUser.isActive) { throw new Error('接收账号已归档或已停用,不能作为素材归属人') } const materials = await prisma.material.findMany({ where: { userId: fromUserId, deletedAt: null, ...(materialTypes?.length ? { materialType: { in: materialTypes } } : {}), }, select: { id: true, userId: true, originalOwnerId: true }, }) const result: BulkTransferResult = { total: materials.length, transferred: 0, failed: 0, dryRun, failures: [], } if (dryRun) return result for (const material of materials) { try { await prisma.$transaction(async (tx) => { await tx.material.update({ where: { id: material.id }, data: { userId: toUserId, // 署名与归属保持一致,避免出现"作者写着甲、归属却是乙" authorName: toUser.username, ...buildMaterialTransferFields({ currentOwnerId: material.userId, existingOriginalOwnerId: material.originalOwnerId, operatorUserId, note, }), }, }) await recordMaterialTransfer(tx, { materialId: material.id, fromUserId: material.userId, toUserId, operatorUserId, note, }) }) result.transferred += 1 } catch (error) { result.failed += 1 result.failures.push({ materialId: material.id, reason: error instanceof Error ? error.message : String(error), }) } } return result } /** 统计某账号名下待交接的素材数量,用于离职处理前的预览 */ export async function countMaterialsByOwner(userId: string): Promise { return prisma.material.count({ where: { userId, deletedAt: null } }) } /** 读取一条素材的完整转移历史,最近一次在前 */ export async function listMaterialTransferHistory(materialId: string) { return prisma.materialTransfer.findMany({ where: { materialId }, orderBy: { transferredAt: 'desc' }, select: { id: true, transferredAt: true, transferNote: true, fromUser: { select: { id: true, username: true, realName: true } }, toUser: { select: { id: true, username: true, realName: true } }, operator: { select: { id: true, username: true, realName: true } }, }, }) } // ============================================================ // 部门托管 // ============================================================ export const CUSTODY_REASON = { RESIGNATION: '原归属人离职,素材由所在部门接管', } as const export interface CustodyResult { /** 待托管素材数 */ total: number /** 实际完成托管数 */ custodied: number departmentName: string | null dryRun: boolean } /** * 把某个账号名下的素材托管给部门。 * * 与 `transferMaterialsBetweenUsers` 的本质区别:**托管不转移归属人,也不改署名**。 * `userId` 与 `authorName` 保持原样——素材是谁做的,永远记在谁名下;变的只是 * 「现在谁负责维护它」。人离职后,其所在部门的成员即可接手。 * * 不指定部门时自动落到该账号所属部门。已处于托管状态的素材会被跳过。 */ export async function custodyMaterialsToDepartment(params: { fromUserId: string /** 不指定时自动取该账号所属部门 */ departmentId?: string reason?: string dryRun?: boolean }): Promise { const { fromUserId, reason = CUSTODY_REASON.RESIGNATION, dryRun = false } = params const user = await prisma.user.findUnique({ where: { id: fromUserId }, select: { username: true, departmentId: true, department: true }, }) if (!user) throw new Error('账号不存在') // 优先用显式指定的部门,其次是账号的部门外键,最后按部门名称兜底反查 let departmentId = params.departmentId ?? user.departmentId ?? null if (!departmentId && user.department?.trim()) { const matched = await prisma.department.findUnique({ where: { name: user.department.trim() }, select: { id: true }, }) departmentId = matched?.id ?? null } if (!departmentId) { throw new Error( `账号「${user.username}」没有可识别的所属部门,无法自动托管,请显式指定部门`, ) } const department = await prisma.department.findUnique({ where: { id: departmentId }, select: { id: true, name: true }, }) if (!department) throw new Error('托管部门不存在') const where = { userId: fromUserId, deletedAt: null, custodyDepartmentId: null, } const total = await prisma.material.count({ where }) if (dryRun) { return { total, custodied: 0, departmentName: department.name, dryRun: true } } const updated = await prisma.material.updateMany({ where, data: { custodyDepartmentId: department.id, custodyAt: new Date(), custodyReason: reason, }, }) return { total, custodied: updated.count, departmentName: department.name, dryRun: false, } } /** * 把所有已归档(离职)账号名下尚未托管的素材,批量交由各自部门接管。 * * 逐个账号处理,某个账号失败(例如识别不出部门)不影响其余账号。 */ export async function custodyMaterialsOfArchivedUsers(params: { reason?: string dryRun?: boolean } = {}): Promise<{ accounts: number totalMaterials: number custodied: number details: Array<{ username: string; department: string | null; count: number }> skipped: Array<{ username: string; count: number; reason: string }> }> { const { reason = CUSTODY_REASON.RESIGNATION, dryRun = false } = params const archived = await prisma.user.findMany({ where: { archivedAt: { not: null } }, select: { id: true, username: true }, }) const details: Array<{ username: string; department: string | null; count: number }> = [] const skipped: Array<{ username: string; count: number; reason: string }> = [] let totalMaterials = 0 let custodied = 0 for (const user of archived) { const pending = await prisma.material.count({ where: { userId: user.id, deletedAt: null, custodyDepartmentId: null }, }) if (pending === 0) continue totalMaterials += pending try { const result = await custodyMaterialsToDepartment({ fromUserId: user.id, reason, dryRun, }) custodied += result.custodied details.push({ username: user.username, department: result.departmentName, count: dryRun ? result.total : result.custodied, }) } catch (error) { skipped.push({ username: user.username, count: pending, reason: error instanceof Error ? error.message : String(error), }) } } return { accounts: details.length, totalMaterials, custodied, details, skipped } }