const crypto = require('crypto'); const jwt = require('jsonwebtoken'); const axios = require('axios'); const bcrypt = require('bcryptjs'); const database = require('../config/database'); const cloudDb = require('../config/cloudDatabase'); const { getAuthConfig } = require('./authConfig'); const { getResolvedUserWithPageAccess } = require('./pageAccess'); const OAUTH_STATE_COOKIE_PREFIX = 'oauth_state_'; const DINGTALK_REQUEST_TIMEOUT_MS = Math.max(Number(process.env.DINGTALK_REQUEST_TIMEOUT_MS) || 20000, 5000); const DINGTALK_REQUEST_MAX_ATTEMPTS = Math.max(Number(process.env.DINGTALK_REQUEST_MAX_ATTEMPTS) || 3, 1); function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function getErrorCode(error) { return error?.code || error?.cause?.code || ''; } function isRetryableDingTalkError(error) { const status = Number(error?.response?.status || 0); if (status === 429 || status >= 500) { return true; } if (error?.response) { return false; } return [ 'ECONNABORTED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'ENETUNREACH', 'EHOSTUNREACH', ].includes(getErrorCode(error)); } async function requestDingTalk(config, label) { let lastError; for (let attempt = 1; attempt <= DINGTALK_REQUEST_MAX_ATTEMPTS; attempt += 1) { try { return await axios.request({ timeout: DINGTALK_REQUEST_TIMEOUT_MS, ...config, }); } catch (error) { lastError = error; if (attempt >= DINGTALK_REQUEST_MAX_ATTEMPTS || !isRetryableDingTalkError(error)) { throw error; } const waitMs = Math.min(1200 * attempt * attempt, 8000); console.warn(`${label} 网络异常,${waitMs}ms 后重试 (${attempt}/${DINGTALK_REQUEST_MAX_ATTEMPTS}): ${getErrorCode(error) || error.message}`); await sleep(waitMs); } } throw lastError; } function signAccessToken(user) { return jwt.sign( { sub: user.id, username: user.username, name: user.name, email: user.email, role: user.role || 'user', picture: user.avatar_url || '', authSource: user.auth_source || 'local', departmentId: user.departmentId || user.department_id || null, departmentName: user.departmentName || '', allowedPageKeys: Array.isArray(user.allowedPageKeys) ? user.allowedPageKeys : [], }, process.env.CUBEJS_SECRET, { expiresIn: '2d' } ); } function buildCallbackUrl(baseUrl, provider) { return `${baseUrl.replace(/\/$/, '')}/callback?provider=${encodeURIComponent(provider)}`; } function createOAuthState(provider, extraClaims = {}) { return jwt.sign( { ...extraClaims, provider, nonce: crypto.randomBytes(16).toString('hex'), }, process.env.CUBEJS_SECRET, { expiresIn: '15m' } ); } function verifyOAuthState(provider, state) { if (!state) { throw new Error('缺少 state 参数'); } const decoded = jwt.verify(state, process.env.CUBEJS_SECRET); if (decoded.provider !== provider) { throw new Error('state 与登录来源不匹配'); } return decoded; } function getStateCookieName(provider) { return `${OAUTH_STATE_COOKIE_PREFIX}${provider}`; } async function issueLoginResult(user) { if (user.status && user.status !== 'active') { throw new Error('当前账号已停用'); } await database.local.update('users', user.id, { last_login_at: new Date(), auth_source: user.auth_source || 'local', }); const resolvedUser = await getResolvedUserWithPageAccess(user.id); const token = signAccessToken(resolvedUser); return { access_token: token, user: { id: resolvedUser.id, username: resolvedUser.username, name: resolvedUser.name, email: resolvedUser.email, role: resolvedUser.role, picture: resolvedUser.picture || '', authSource: resolvedUser.auth_source || 'local', departmentId: resolvedUser.departmentId, departmentName: resolvedUser.departmentName || '', allowedPageKeys: resolvedUser.allowedPageKeys || [], }, }; } async function findOne(sql, params = []) { const rows = await database.local.query(sql, params); return rows[0] || null; } async function generateUniqueUsername(baseUsername) { const normalized = String(baseUsername || 'user') .trim() .toLowerCase() .replace(/[^a-z0-9_]+/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 30) || 'user'; let candidate = normalized; let counter = 1; while (true) { const existing = await database.local.query('SELECT id FROM users WHERE username = ? LIMIT 1', [candidate]); if (existing.length === 0) { return candidate; } counter += 1; candidate = `${normalized.slice(0, Math.max(1, 26))}_${counter}`; } } function getDepartmentSourcePriority(source) { if (source === 'dingtalk') { return 0; } if (source === 'hr') { return 1; } if (source === 'local') { return 2; } return 3; } function buildDepartmentCode(source, externalId) { return `${String(source || 'local').toUpperCase()}_${externalId}`; } async function findDepartmentByExternalId(externalId) { if (externalId === null || externalId === undefined || String(externalId).trim() === '') { return null; } const rows = await database.local.query( `SELECT id, name, code, parent_id, source, external_id FROM departments WHERE external_id = ? ORDER BY CASE source WHEN 'dingtalk' THEN 0 WHEN 'hr' THEN 1 WHEN 'local' THEN 2 ELSE 3 END ASC, id ASC LIMIT 1`, [String(externalId)] ); return rows[0] || null; } async function ensureDepartmentRecord(deptId, deptName, source = 'dingtalk', parentExternalId = null) { let parentId = 0; if (parentExternalId) { const parentDepartment = await findDepartmentByExternalId(parentExternalId); parentId = parentDepartment?.id || 0; } const existing = await findDepartmentByExternalId(deptId); if (existing) { const nextUpdate = {}; if (deptName && existing.name !== deptName) { nextUpdate.name = deptName; } if (existing.parent_id !== parentId) { nextUpdate.parent_id = parentId; } if (source === 'dingtalk' && existing.source !== 'dingtalk') { nextUpdate.source = 'dingtalk'; nextUpdate.code = buildDepartmentCode('dingtalk', deptId); } if (existing.external_id !== String(deptId)) { nextUpdate.external_id = String(deptId); } if (Object.keys(nextUpdate).length > 0) { await database.local.update('departments', existing.id, nextUpdate); } return existing.id; } const insertResult = await database.local.insert('departments', { name: deptName || `部门${deptId}`, code: buildDepartmentCode(source, deptId), parent_id: parentId, sort: 0, status: 'active', source, external_id: String(deptId), }); return insertResult.insertId; } function pickCanonicalDepartment(rows) { return rows.slice().sort((left, right) => { const metrics = ['userCount', 'planningAccessCount', 'planningRecordCount', 'permissionCount']; for (const metric of metrics) { const diff = Number(right[metric] || 0) - Number(left[metric] || 0); if (diff !== 0) { return diff; } } const sourceDiff = getDepartmentSourcePriority(left.source) - getDepartmentSourcePriority(right.source); if (sourceDiff !== 0) { return sourceDiff; } return Number(left.id) - Number(right.id); })[0]; } async function mergeDepartmentPermissionRows(connection, targetId, sourceId) { const [rows] = await connection.query( `SELECT page_key as pageKey, page_name as pageName, allowed FROM department_page_permissions WHERE department_id = ?`, [sourceId] ); for (const row of rows) { const [existingRows] = await connection.query( `SELECT id, allowed FROM department_page_permissions WHERE department_id = ? AND page_key = ? LIMIT 1`, [targetId, row.pageKey] ); const existing = existingRows[0]; const nextAllowed = (existing?.allowed === 1 || existing?.allowed === true || row.allowed === 1 || row.allowed === true) ? 1 : 0; if (existing) { await connection.query( `UPDATE department_page_permissions SET page_name = ?, allowed = ? WHERE id = ?`, [row.pageName, nextAllowed, existing.id] ); continue; } await connection.query( `INSERT INTO department_page_permissions (department_id, page_key, page_name, allowed) VALUES (?, ?, ?, ?)`, [targetId, row.pageKey, row.pageName, nextAllowed] ); } } async function hasTable(connection, tableName) { const [rows] = await connection.query('SHOW TABLES LIKE ?', [tableName]); return rows.length > 0; } async function repairDuplicateExternalDepartments() { const duplicateGroups = await database.local.query( `SELECT external_id as externalId FROM departments WHERE external_id IS NOT NULL AND external_id <> '' GROUP BY external_id HAVING COUNT(*) > 1` ); let mergedCount = 0; for (const group of duplicateGroups) { const externalId = String(group.externalId); const connection = await database.local.pool.getConnection(); try { await connection.beginTransaction(); const hasPlanningRecordTable = await hasTable(connection, 'planning_upload_records'); const [rows] = await connection.query( `SELECT d.id, d.name, d.code, d.parent_id, d.source, d.external_id, d.sort, d.status, (SELECT COUNT(*) FROM users u WHERE u.department_id = d.id) AS userCount, (SELECT COUNT(*) FROM department_page_permissions p WHERE p.department_id = d.id) AS permissionCount, ${hasPlanningRecordTable ? '(SELECT COUNT(*) FROM planning_upload_records pur WHERE pur.uploaded_department_id = d.id)' : '0'} AS planningRecordCount FROM departments d WHERE d.external_id = ? ORDER BY CASE d.source WHEN 'dingtalk' THEN 0 WHEN 'hr' THEN 1 WHEN 'local' THEN 2 ELSE 3 END ASC, d.id ASC`, [externalId] ); if (rows.length < 2) { await connection.commit(); continue; } const canonical = pickCanonicalDepartment(rows); const duplicates = rows.filter((row) => row.id !== canonical.id); const groupIds = new Set(rows.map((row) => Number(row.id))); const preferredDingTalk = rows.find((row) => row.source === 'dingtalk'); for (const duplicate of duplicates) { await connection.query('UPDATE users SET department_id = ? WHERE department_id = ?', [canonical.id, duplicate.id]); await connection.query('UPDATE departments SET parent_id = ? WHERE parent_id = ?', [canonical.id, duplicate.id]); if (hasPlanningRecordTable) { await connection.query( 'UPDATE planning_upload_records SET uploaded_department_id = ? WHERE uploaded_department_id = ?', [canonical.id, duplicate.id] ); } await mergeDepartmentPermissionRows(connection, canonical.id, duplicate.id); await connection.query('DELETE FROM department_page_permissions WHERE department_id = ?', [duplicate.id]); await connection.query('DELETE FROM departments WHERE id = ?', [duplicate.id]); mergedCount += 1; } const preferredParentId = preferredDingTalk?.parent_id && !groupIds.has(Number(preferredDingTalk.parent_id)) ? preferredDingTalk.parent_id : (!groupIds.has(Number(canonical.parent_id)) ? canonical.parent_id : 0); const nextSource = preferredDingTalk ? 'dingtalk' : canonical.source; const nextCode = buildDepartmentCode(nextSource, externalId); const nextName = preferredDingTalk?.name || canonical.name || `部门${externalId}`; await connection.query( `UPDATE departments SET name = ?, code = ?, parent_id = ?, source = ?, external_id = ? WHERE id = ?`, [nextName, nextCode, preferredParentId || 0, nextSource, externalId, canonical.id] ); await connection.commit(); } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } return { duplicateGroups: duplicateGroups.length, mergedCount, }; } async function resolveTopDepartmentFromHrGroups(departmentIds = []) { const normalizedIds = Array.from(new Set( departmentIds .filter((value) => value !== null && value !== undefined && String(value).trim() !== '') .map((value) => String(value).trim()) )); for (const departmentId of normalizedIds) { let rows = []; try { rows = await cloudDb.query( ` WITH dept_tree AS ( SELECT [Id] AS id, [parent_id] AS parentId, [name] AS name, 0 AS lvl FROM dbo.hr_groups WHERE CAST([Id] AS NVARCHAR(100)) = @departmentId UNION ALL SELECT g.[Id] AS id, g.[parent_id] AS parentId, g.[name] AS name, dt.lvl + 1 AS lvl FROM dbo.hr_groups g INNER JOIN dept_tree dt ON g.[Id] = dt.parentId WHERE dt.parentId IS NOT NULL ) SELECT TOP (1) id, parentId, name, lvl FROM dept_tree WHERE parentId = 1 ORDER BY lvl DESC, id ASC `, { departmentId }, { requestTimeout: 5000, maxAttempts: 1, } ); } catch (error) { console.warn(`[SocialAuth] hr_groups 查询失败,跳过一级部门映射,departmentId=${departmentId}: ${error.message}`); continue; } const topDepartment = rows?.[0]; if (topDepartment?.id && topDepartment?.name) { return { departmentName: String(topDepartment.name).trim(), departmentExternalId: String(topDepartment.id), departmentParentExternalId: topDepartment.parentId ? String(topDepartment.parentId) : null, sourceDepartmentId: departmentId, }; } } return null; } async function upsertUserFromSocial(profile) { const email = profile.email || `${profile.provider}_${profile.externalUserId || profile.externalUnionId || Date.now()}@local.oauth`; const userQueries = []; const params = []; if (profile.provider === 'dingtalk') { if (profile.externalUserId) { userQueries.push('dingtalk_user_id = ?'); params.push(profile.externalUserId); } if (profile.externalUnionId) { userQueries.push('dingtalk_union_id = ?'); params.push(profile.externalUnionId); } } if (profile.provider === 'google' && profile.googleSub) { userQueries.push('google_sub = ?'); params.push(profile.googleSub); } userQueries.push('email = ?'); params.push(email); if (profile.mobile) { userQueries.push('mobile = ?'); params.push(profile.mobile); } const existingUser = await findOne( `SELECT id, username, name, email, role, status, department_id, password_hash, avatar_url, mobile, job_title, dingtalk_user_id, dingtalk_union_id, google_sub, department_names, auth_source FROM users WHERE ${userQueries.join(' OR ')} LIMIT 1`, params ); const isSyntheticEmail = (value) => typeof value === 'string' && (value.endsWith('@dingtalk.local') || value.endsWith('@local.oauth')); const resolvedEmail = existingUser && isSyntheticEmail(email) && existingUser.email && !isSyntheticEmail(existingUser.email) ? existingUser.email : email; const hrDepartment = profile.provider === 'dingtalk' ? await resolveTopDepartmentFromHrGroups((profile.departments || []).map((item) => item?.id)) : null; let primaryDepartmentId = null; if (hrDepartment?.departmentExternalId) { primaryDepartmentId = await ensureDepartmentRecord( hrDepartment.departmentExternalId, hrDepartment.departmentName, 'hr', hrDepartment.departmentParentExternalId ); } if (Array.isArray(profile.departments) && profile.departments.length > 0) { primaryDepartmentId = primaryDepartmentId || await ensureDepartmentRecord( profile.departments[0].id, profile.departments[0].name, 'dingtalk' ); } const nextData = { name: profile.name || existingUser?.name || profile.username, email: resolvedEmail, avatar_url: profile.avatarUrl || existingUser?.avatar_url || null, mobile: profile.mobile || existingUser?.mobile || null, job_title: profile.jobTitle || existingUser?.job_title || null, auth_source: profile.provider, department_id: primaryDepartmentId || existingUser?.department_id || null, department_names: hrDepartment?.departmentName || profile.departmentNames || existingUser?.department_names || null, dingtalk_user_id: profile.provider === 'dingtalk' ? (profile.externalUserId || existingUser?.dingtalk_user_id || null) : existingUser?.dingtalk_user_id || null, dingtalk_union_id: profile.provider === 'dingtalk' ? (profile.externalUnionId || existingUser?.dingtalk_union_id || null) : existingUser?.dingtalk_union_id || null, google_sub: profile.provider === 'google' ? (profile.googleSub || existingUser?.google_sub || null) : existingUser?.google_sub || null, }; if (existingUser) { await database.local.update('users', existingUser.id, nextData); return findOne( `SELECT id, username, name, email, role, status, avatar_url, auth_source FROM users WHERE id = ?`, [existingUser.id] ); } const username = await generateUniqueUsername(profile.username || resolvedEmail.split('@')[0] || `${profile.provider}_user`); const emptyPassword = await bcrypt.hash(crypto.randomBytes(16).toString('hex'), 10); const insertResult = await database.local.insert('users', { username, password_hash: emptyPassword, name: nextData.name || username, email: resolvedEmail, department_id: nextData.department_id, role: 'user', status: 'active', auth_source: nextData.auth_source, avatar_url: nextData.avatar_url, mobile: nextData.mobile, job_title: nextData.job_title, dingtalk_user_id: nextData.dingtalk_user_id, dingtalk_union_id: nextData.dingtalk_union_id, google_sub: nextData.google_sub, department_names: nextData.department_names, }); return findOne( `SELECT id, username, name, email, role, status, avatar_url, auth_source FROM users WHERE id = ?`, [insertResult.insertId] ); } async function getDingTalkUserAccessToken(code, appKey, appSecret) { const response = await requestDingTalk({ method: 'post', url: 'https://api.dingtalk.com/v1.0/oauth2/userAccessToken', data: { clientId: appKey, clientSecret: appSecret, code, grantType: 'authorization_code', }, headers: { 'Content-Type': 'application/json', }, }, '获取钉钉用户 access token'); if (!response.data?.accessToken) { throw new Error(response.data?.message || '获取钉钉用户 access token 失败'); } return response.data.accessToken; } async function getDingTalkAppAccessToken(appKey, appSecret) { const response = await requestDingTalk({ method: 'get', url: 'https://oapi.dingtalk.com/gettoken', params: { appkey: appKey, appsecret: appSecret, }, }, '获取钉钉应用 access token'); if (!response.data?.access_token) { throw new Error(response.data?.errmsg || '获取钉钉应用 access token 失败'); } return response.data.access_token; } async function getDingTalkDepartmentName(appToken, deptId) { const response = await requestDingTalk({ method: 'post', url: `https://oapi.dingtalk.com/topapi/v2/department/get?access_token=${encodeURIComponent(appToken)}`, data: { dept_id: deptId }, headers: { 'Content-Type': 'application/json', }, }, `获取钉钉部门 ${deptId}`); if (response.data?.errcode !== 0) { throw new Error(response.data?.errmsg || `获取钉钉部门 ${deptId} 失败`); } return response.data?.result?.name || ''; } async function exchangeDingTalkCode(code) { const config = await getAuthConfig(); if (!config.dingtalkEnabled || !config.dingtalkAppKey || !config.dingtalkAppSecret) { throw new Error('钉钉登录未配置或未启用'); } const userAccessToken = await getDingTalkUserAccessToken(code, config.dingtalkAppKey, config.dingtalkAppSecret); const meResponse = await requestDingTalk({ method: 'get', url: 'https://api.dingtalk.com/v1.0/contact/users/me', headers: { 'x-acs-dingtalk-access-token': userAccessToken, }, }, '获取钉钉用户基础信息'); if (!meResponse.data?.unionId) { throw new Error('获取钉钉 unionId 失败'); } const appToken = await getDingTalkAppAccessToken(config.dingtalkAppKey, config.dingtalkAppSecret); const userIdResponse = await requestDingTalk({ method: 'post', url: `https://oapi.dingtalk.com/topapi/user/getbyunionid?access_token=${encodeURIComponent(appToken)}`, data: { unionid: meResponse.data.unionId }, headers: { 'Content-Type': 'application/json', }, }, '通过 unionId 获取钉钉用户 ID'); const userId = userIdResponse.data?.result?.userid; if (!userId) { throw new Error(userIdResponse.data?.errmsg || '获取钉钉用户ID失败'); } const detailResponse = await requestDingTalk({ method: 'post', url: `https://oapi.dingtalk.com/topapi/v2/user/get?access_token=${encodeURIComponent(appToken)}`, data: { userid: userId }, headers: { 'Content-Type': 'application/json', }, }, '获取钉钉用户详情'); if (detailResponse.data?.errcode !== 0 || !detailResponse.data?.result) { throw new Error(detailResponse.data?.errmsg || '获取钉钉用户详情失败'); } const detail = detailResponse.data.result; const deptIds = Array.isArray(detail.dept_id_list) ? detail.dept_id_list : []; const departments = []; for (const deptId of deptIds) { try { const deptName = await getDingTalkDepartmentName(appToken, deptId); departments.push({ id: deptId, name: deptName || `部门${deptId}` }); } catch (error) { departments.push({ id: deptId, name: `部门${deptId}` }); } } const email = detail.email || detail.org_email || (meResponse.data.unionId ? `dingtalk_${meResponse.data.unionId}@dingtalk.local` : ''); return { provider: 'dingtalk', username: detail.job_number || detail.name || `ding_${userId}`, name: detail.name || meResponse.data.nick || `钉钉用户${userId}`, email, mobile: detail.mobile || null, jobTitle: detail.title || null, avatarUrl: detail.avatar || meResponse.data.avatarUrl || null, externalUserId: userId, externalUnionId: meResponse.data.unionId, departmentNames: departments.map((item) => item.name).join(' / '), departments, }; } async function exchangeGoogleCode(code, redirectUri) { const config = await getAuthConfig(); if (!config.googleEnabled || !config.googleClientId || !config.googleClientSecret) { throw new Error('Google 登录未配置或未启用'); } const tokenParams = new URLSearchParams({ code, client_id: config.googleClientId, client_secret: config.googleClientSecret, redirect_uri: redirectUri, grant_type: 'authorization_code', }); const tokenResponse = await axios.post('https://oauth2.googleapis.com/token', tokenParams.toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, timeout: 15000, }); if (!tokenResponse.data?.access_token) { throw new Error('获取 Google access token 失败'); } const profileResponse = await axios.get('https://openidconnect.googleapis.com/v1/userinfo', { headers: { Authorization: `Bearer ${tokenResponse.data.access_token}`, }, timeout: 15000, }); if (!profileResponse.data?.sub || !profileResponse.data?.email) { throw new Error('获取 Google 用户信息失败'); } return { provider: 'google', username: profileResponse.data.email.split('@')[0], name: profileResponse.data.name || profileResponse.data.email, email: profileResponse.data.email, avatarUrl: profileResponse.data.picture || null, googleSub: profileResponse.data.sub, departmentNames: null, departments: [], }; } async function listDingTalkSubDepartments(appToken, parentDeptId) { const response = await requestDingTalk({ method: 'post', url: `https://oapi.dingtalk.com/topapi/v2/department/listsub?access_token=${encodeURIComponent(appToken)}`, data: { dept_id: parentDeptId, }, headers: { 'Content-Type': 'application/json', }, }, `获取钉钉部门 ${parentDeptId} 子部门`); if (response.data?.errcode !== 0) { throw new Error(response.data?.errmsg || `获取部门 ${parentDeptId} 子部门失败`); } return Array.isArray(response.data?.result) ? response.data.result : []; } async function listDingTalkDepartmentUsers(appToken, deptId, cursor = 0, result = []) { const response = await requestDingTalk({ method: 'post', url: `https://oapi.dingtalk.com/topapi/v2/user/list?access_token=${encodeURIComponent(appToken)}`, data: { dept_id: deptId, cursor, size: 100, }, headers: { 'Content-Type': 'application/json', }, }, `获取钉钉部门 ${deptId} 用户列表`); if (response.data?.errcode !== 0) { throw new Error(response.data?.errmsg || `获取部门 ${deptId} 用户列表失败`); } const nextResult = result.concat(response.data?.result?.list || []); if (response.data?.result?.has_more && Number.isFinite(response.data?.result?.next_cursor)) { return listDingTalkDepartmentUsers(appToken, deptId, response.data.result.next_cursor, nextResult); } return nextResult; } async function syncDingTalkUsers() { const config = await getAuthConfig(); if (!config.dingtalkEnabled || !config.dingtalkAppKey || !config.dingtalkAppSecret) { throw new Error('钉钉登录未配置或未启用'); } const appToken = await getDingTalkAppAccessToken(config.dingtalkAppKey, config.dingtalkAppSecret); const rootDeptId = Number(config.dingtalkSyncRootDeptId || 1); const queue = [{ deptId: rootDeptId, deptName: '根部门', parentExternalId: null }]; const visited = new Set(); const deptNameMap = new Map(); const userMap = new Map(); while (queue.length > 0) { const current = queue.shift(); if (!current || visited.has(current.deptId)) { continue; } visited.add(current.deptId); if (current.deptName) { deptNameMap.set(current.deptId, current.deptName); await ensureDepartmentRecord(current.deptId, current.deptName, 'dingtalk', current.parentExternalId); } const subDepartments = await listDingTalkSubDepartments(appToken, current.deptId); for (const dept of subDepartments) { deptNameMap.set(dept.dept_id, dept.name); await ensureDepartmentRecord(dept.dept_id, dept.name, 'dingtalk', current.deptId); queue.push({ deptId: dept.dept_id, deptName: dept.name, parentExternalId: current.deptId }); } const users = await listDingTalkDepartmentUsers(appToken, current.deptId); users.forEach((user) => { const existing = userMap.get(user.userid) || user; const existingDeptIds = Array.isArray(existing.dept_id_list) ? existing.dept_id_list : []; const nextDeptIds = Array.isArray(user.dept_id_list) ? user.dept_id_list : []; userMap.set(user.userid, { ...existing, ...user, dept_id_list: Array.from(new Set(existingDeptIds.concat(nextDeptIds))), }); }); } let created = 0; let updated = 0; for (const dtUser of userMap.values()) { const departments = (Array.isArray(dtUser.dept_id_list) ? dtUser.dept_id_list : []).map((deptId) => ({ id: deptId, name: deptNameMap.get(deptId) || `部门${deptId}`, })); const profile = { provider: 'dingtalk', username: dtUser.job_number || dtUser.name || `ding_${dtUser.userid}`, name: dtUser.name || `钉钉用户${dtUser.userid}`, email: dtUser.email || dtUser.org_email || `dingtalk_${dtUser.userid}@dingtalk.local`, mobile: dtUser.mobile || null, jobTitle: dtUser.title || null, avatarUrl: dtUser.avatar || null, externalUserId: dtUser.userid, externalUnionId: dtUser.unionid || null, departmentNames: departments.map((item) => item.name).join(' / '), departments, }; const before = await findOne( 'SELECT id FROM users WHERE dingtalk_user_id = ? OR email = ? LIMIT 1', [profile.externalUserId, profile.email] ); await upsertUserFromSocial(profile); if (before) { updated += 1; } else { created += 1; } } return { total: userMap.size, created, updated, departments: deptNameMap.size, }; } module.exports = { buildCallbackUrl, createOAuthState, exchangeDingTalkCode, exchangeGoogleCode, getStateCookieName, issueLoginResult, signAccessToken, repairDuplicateExternalDepartments, syncDingTalkUsers, upsertUserFromSocial, verifyOAuthState, };