素材库与考核调研此前各有一套人事数据,彼此没有外键,靠姓名、邮箱在运行时 逐层软匹配。本次把身份判定收敛到钉钉唯一值,并让组织架构与人员状态跟随 上游 HR 系统自动更新。 人员身份 - 新增账号与权威人员档案的正式绑定表,活跃绑定双向唯一 - users.dingtalkUserId 补上唯一约束,数据库层面杜绝一人多号 - 移除钉钉登录的同名自动合并,同名冲突一律转人工核对 - 看板删除特判由比对用户名改为比对钉钉唯一值,避免同名越权 组织架构 - 新增 OrganizationUnit,直接镜像上游 HR 部门树 - 不再从员工部门路径倒推,同名部门不会再被合并 - 上游已删除的部门下线而非物理删除 人员状态 - 新增规范化的 employmentState,取代对混杂原始值做关键词匹配 - 接入上游离职表,离职判定不再依赖中文关键词 - 离职联动:归档账号并把素材托管给所在部门,默认只处理离职满 30 天者 素材归属 - 归属变更纳入正式转移,记录最初上传者、转移流水与理由 - 新增素材托管部门,归属与署名分离:托管只改维护责任,不动署名 - 编辑素材的权限判定统一到 canManageMaterial - 修复任务-详情上传把邮箱误存为作者名的问题 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
103 lines
3.3 KiB
TypeScript
103 lines
3.3 KiB
TypeScript
/**
|
||
* 回填作者名:把误存成邮箱的 authorName 改回真实用户名
|
||
*
|
||
* 成因:任务-详情上传入口曾读取 session.user.username,而登录态从不写入该字段,
|
||
* 一路兜底存成了邮箱。入口已修复,本脚本清理存量。
|
||
*
|
||
* 只改 authorName(署名展示用),不动 userId(归属)。
|
||
* 取值口径与其它上传入口一致:使用 users.username。
|
||
*
|
||
* 默认预演,不写任何数据。加 --apply 才实际执行。
|
||
*
|
||
* 用法:
|
||
* npx tsx scripts/backfill-email-author-names.ts # 预演
|
||
* npx tsx scripts/backfill-email-author-names.ts --apply # 实际执行
|
||
*/
|
||
|
||
import { PrismaClient } from '@prisma/client'
|
||
|
||
const prisma = new PrismaClient({ log: [] })
|
||
|
||
async function main() {
|
||
const apply = process.argv.includes('--apply')
|
||
|
||
console.log('\n回填被误存为邮箱的作者名')
|
||
console.log('模式:' + (apply ? '⚠️ 实际执行' : '预演,不写入'))
|
||
|
||
const targets = await prisma.material.findMany({
|
||
where: { deletedAt: null, authorName: { contains: '@' } },
|
||
select: {
|
||
id: true,
|
||
authorName: true,
|
||
materialType: true,
|
||
user: { select: { username: true, realName: true, archivedAt: true } },
|
||
},
|
||
})
|
||
|
||
console.log('\n 作者名含邮箱的素材 ' + targets.length + ' 条')
|
||
|
||
const fixable = targets.filter((m) => m.user?.username && m.user.username.trim() !== '')
|
||
const unfixable = targets.filter((m) => !m.user?.username || m.user.username.trim() === '')
|
||
|
||
console.log(' 可按归属账号回填 ' + fixable.length + ' 条')
|
||
console.log(' 无法回填(账号缺失) ' + unfixable.length + ' 条')
|
||
|
||
const byType = new Map<string, number>()
|
||
fixable.forEach((m) => {
|
||
const k = m.materialType || '(无类型)'
|
||
byType.set(k, (byType.get(k) || 0) + 1)
|
||
})
|
||
console.log('\n 按素材类型分布:')
|
||
Array.from(byType.entries())
|
||
.sort((a, b) => b[1] - a[1])
|
||
.forEach(([t, c]) => console.log(' ' + t.padEnd(20, '.') + c))
|
||
|
||
const samples = fixable.slice(0, 8)
|
||
console.log('\n 回填样本(前 8 条):')
|
||
samples.forEach((m) =>
|
||
console.log(
|
||
' ' + (m.authorName ?? '').padEnd(34) + ' → ' + m.user!.username +
|
||
(m.user!.archivedAt ? '(已离职)' : ''),
|
||
),
|
||
)
|
||
|
||
if (!apply) {
|
||
console.log('\n(预演结束,未写入任何数据。加 --apply 实际执行)')
|
||
await prisma.$disconnect()
|
||
return
|
||
}
|
||
|
||
// 按目标用户名分组批量更新,避免逐条往返
|
||
const groups = new Map<string, string[]>()
|
||
fixable.forEach((m) => {
|
||
const name = m.user!.username
|
||
const list = groups.get(name) ?? []
|
||
list.push(m.id)
|
||
groups.set(name, list)
|
||
})
|
||
|
||
let updated = 0
|
||
for (const [username, ids] of groups) {
|
||
const result = await prisma.material.updateMany({
|
||
where: { id: { in: ids } },
|
||
data: { authorName: username },
|
||
})
|
||
updated += result.count
|
||
}
|
||
|
||
console.log('\n 已回填 ' + updated + ' 条')
|
||
|
||
const remaining = await prisma.material.count({
|
||
where: { deletedAt: null, authorName: { contains: '@' } },
|
||
})
|
||
console.log(' 剩余作者名含邮箱的 ' + remaining + ' 条' + (remaining === unfixable.length ? '(均为无法回填的)' : ''))
|
||
|
||
await prisma.$disconnect()
|
||
}
|
||
|
||
main().catch(async (e) => {
|
||
console.error('\n执行失败:', e)
|
||
await prisma.$disconnect()
|
||
process.exitCode = 1
|
||
})
|