cangku/backend/src/services/socialAuth.js

928 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
};