-- 素材库账号 ↔ 权威人员档案 正式绑定关系 -- -- 背景:此前账号与花名册人员之间没有任何外键,仅在运行时按 -- 钉钉ID → 邮箱 → sourcePayload JSON 逐层软匹配,数据库层面 -- 并不知道某个素材库账号和某条花名册档案是同一个人。 -- -- 本迁移建立正式绑定表,并按钉钉唯一值回填存量绑定。 -- 严格遵守:只按钉钉唯一值自动绑定,姓名与邮箱不参与自动建立绑定。 -- -- 可重复执行。 BEGIN; -- ========== 1. 前置校验 ========== -- 一个钉钉身份对应多个素材库账号时,回填会在同一人员档案上产生 -- 多条 ACTIVE 绑定,违反下面的部分唯一索引。此处提前拦截并给出明确原因, -- 而不是让索引创建阶段抛出难以定位的报错。 DO $$ DECLARE conflict_count INTEGER; BEGIN SELECT COUNT(*) INTO conflict_count FROM ( SELECT "dingtalkUserId" FROM users WHERE "dingtalkUserId" IS NOT NULL GROUP BY "dingtalkUserId" HAVING COUNT(*) > 1 ) AS duplicated; IF conflict_count > 0 THEN RAISE EXCEPTION '存在 % 个钉钉账号绑定了多个素材库账号,必须先人工处理后再执行本迁移。禁止按姓名自动合并。', conflict_count; END IF; END $$; -- ========== 2. 钉钉唯一值:全项目唯一 ========== -- 此前 users."dingtalkUserId" 没有任何唯一约束,数据库层面允许两个素材库账号 -- 填入同一个钉钉身份。既然钉钉唯一值是全项目唯一的人员判定依据,这条约束必须落到库上。 -- PostgreSQL 中多个 NULL 互不冲突,因此未绑定钉钉的账号不受影响。 CREATE UNIQUE INDEX IF NOT EXISTS "users_dingtalkUserId_key" ON users("dingtalkUserId"); -- ========== 3. 建表 ========== CREATE TABLE IF NOT EXISTS user_employee_bindings ( id TEXT NOT NULL, user_id TEXT NOT NULL, employee_profile_id TEXT NOT NULL, binding_source TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'ACTIVE', verified_at TIMESTAMP(3), note TEXT, created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP(3) NOT NULL, CONSTRAINT user_employee_bindings_pkey PRIMARY KEY (id) ); -- 外键:账号或人员档案被删除时,绑定关系一并清除 DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'user_employee_bindings_user_id_fkey' ) THEN ALTER TABLE user_employee_bindings ADD CONSTRAINT user_employee_bindings_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE; END IF; IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'user_employee_bindings_profile_id_fkey' ) THEN ALTER TABLE user_employee_bindings ADD CONSTRAINT user_employee_bindings_profile_id_fkey FOREIGN KEY (employee_profile_id) REFERENCES performance_survey_employee_profiles(id) ON DELETE CASCADE ON UPDATE CASCADE; END IF; END $$; -- ========== 4. 索引 ========== CREATE INDEX IF NOT EXISTS user_employee_binding_user_idx ON user_employee_bindings(user_id); CREATE INDEX IF NOT EXISTS user_employee_binding_profile_idx ON user_employee_bindings(employee_profile_id); CREATE INDEX IF NOT EXISTS user_employee_binding_status_idx ON user_employee_bindings(status); -- 活跃绑定的双向唯一:一个账号只能绑一名权威人员,一名权威人员只能绑一个活跃账号。 -- 使用部分唯一索引,从而允许保留历史(status <> 'ACTIVE')绑定记录。 CREATE UNIQUE INDEX IF NOT EXISTS user_employee_binding_active_user_key ON user_employee_bindings(user_id) WHERE status = 'ACTIVE'; CREATE UNIQUE INDEX IF NOT EXISTS user_employee_binding_active_profile_key ON user_employee_bindings(employee_profile_id) WHERE status = 'ACTIVE'; -- ========== 5. 回填存量绑定 ========== -- 只按钉钉唯一值匹配。行 ID 由 user_id + profile_id 确定性生成,保证重复执行幂等。 INSERT INTO user_employee_bindings ( id, user_id, employee_profile_id, binding_source, status, verified_at, created_at, updated_at ) SELECT md5('user_employee_binding:' || u.id || ':' || p.id)::uuid::text, u.id, p.id, 'MIGRATION', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP FROM users u JOIN performance_survey_employee_profiles p ON p.dingtalk_user_id = u."dingtalkUserId" WHERE u."dingtalkUserId" IS NOT NULL AND u."dingtalkUserId" <> '' AND NOT EXISTS ( SELECT 1 FROM user_employee_bindings b WHERE b.user_id = u.id AND b.status = 'ACTIVE' ) AND NOT EXISTS ( SELECT 1 FROM user_employee_bindings b WHERE b.employee_profile_id = p.id AND b.status = 'ACTIVE' ); COMMIT;