forked from lunanting/cangku
928 lines
29 KiB
JavaScript
928 lines
29 KiB
JavaScript
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,
|
||
};
|