dy-dashboard/services/dingtalk_organization.py
2026-10-29 16:33:09 +08:00

341 lines
15 KiB
Python
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.

"""钉钉组织架构只读同步服务。"""
from __future__ import annotations
import json
import os
from datetime import datetime
from pathlib import Path
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from services.db import get_db, query
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SECRET_FILE = PROJECT_ROOT / '.secrets' / 'dingtalk_organization.json'
def load_dingtalk_config():
config = {
'app_key': os.environ.get('DINGTALK_ORG_APP_KEY', '').strip(),
'app_secret': os.environ.get('DINGTALK_ORG_APP_SECRET', '').strip(),
'corp_id': os.environ.get('DINGTALK_ORG_CORP_ID', '').strip(),
}
if SECRET_FILE.exists():
with SECRET_FILE.open(encoding='utf-8') as handle:
saved = json.load(handle)
for key in config:
config[key] = config[key] or str(saved.get(key) or '').strip()
missing = [key for key, value in config.items() if not value]
if missing:
raise RuntimeError('钉钉组织架构配置不完整:' + ', '.join(missing))
return config
def ensure_organization_schema():
statements = [
"""CREATE TABLE IF NOT EXISTS dingtalk_org_departments (
dept_id BIGINT NOT NULL PRIMARY KEY,
parent_id BIGINT NOT NULL DEFAULT 0,
name VARCHAR(200) NOT NULL DEFAULT '',
sort_order BIGINT NOT NULL DEFAULT 0,
active TINYINT NOT NULL DEFAULT 1,
synced_at DATETIME NOT NULL,
KEY idx_dingtalk_dept_parent (parent_id, active, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"""CREATE TABLE IF NOT EXISTS dingtalk_org_users (
userid VARCHAR(128) NOT NULL PRIMARY KEY,
unionid VARCHAR(128) NOT NULL DEFAULT '',
name VARCHAR(100) NOT NULL DEFAULT '',
mobile VARCHAR(32) NOT NULL DEFAULT '',
title VARCHAR(200) NOT NULL DEFAULT '',
job_number VARCHAR(100) NOT NULL DEFAULT '',
avatar VARCHAR(1000) NOT NULL DEFAULT '',
active TINYINT NOT NULL DEFAULT 1,
is_admin TINYINT NOT NULL DEFAULT 0,
is_boss TINYINT NOT NULL DEFAULT 0,
hired_date BIGINT NULL,
matched_dashboard_user_id INT NULL,
synced_at DATETIME NOT NULL,
KEY idx_dingtalk_user_name (name),
KEY idx_dingtalk_user_mobile (mobile),
KEY idx_dingtalk_user_match (matched_dashboard_user_id),
CONSTRAINT fk_dingtalk_user_match FOREIGN KEY (matched_dashboard_user_id)
REFERENCES dashboard_users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"""CREATE TABLE IF NOT EXISTS dingtalk_org_memberships (
dept_id BIGINT NOT NULL,
userid VARCHAR(128) NOT NULL,
is_leader TINYINT NOT NULL DEFAULT 0,
dept_order BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (dept_id, userid),
KEY idx_dingtalk_membership_user (userid),
CONSTRAINT fk_dingtalk_membership_dept FOREIGN KEY (dept_id)
REFERENCES dingtalk_org_departments(dept_id) ON DELETE CASCADE,
CONSTRAINT fk_dingtalk_membership_user FOREIGN KEY (userid)
REFERENCES dingtalk_org_users(userid) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"""CREATE TABLE IF NOT EXISTS dingtalk_org_sync_runs (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
trigger_type VARCHAR(20) NOT NULL DEFAULT 'manual',
status VARCHAR(20) NOT NULL DEFAULT 'running',
department_count INT NOT NULL DEFAULT 0,
user_count INT NOT NULL DEFAULT 0,
matched_count INT NOT NULL DEFAULT 0,
message VARCHAR(1000) NOT NULL DEFAULT '',
started_at DATETIME NOT NULL,
finished_at DATETIME NULL,
KEY idx_dingtalk_sync_started (started_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
]
for statement in statements:
query(statement)
def _request_json(url, payload=None):
body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode('utf-8')
request = Request(url, data=body, headers={'Content-Type': 'application/json'})
with urlopen(request, timeout=30) as response:
result = json.loads(response.read().decode('utf-8'))
if int(result.get('errcode') or 0) != 0:
raise RuntimeError(f"钉钉接口错误 {result.get('errcode')}:{result.get('errmsg') or '未知错误'}")
return result
def _access_token(config):
result = _request_json(
'https://oapi.dingtalk.com/gettoken?' + urlencode({
'appkey': config['app_key'],
'appsecret': config['app_secret'],
})
)
token = str(result.get('access_token') or '')
if not token:
raise RuntimeError('钉钉未返回 access_token')
return token
def _post(token, path, payload):
return _request_json(f'https://oapi.dingtalk.com/{path}?access_token={token}', payload)
def _fetch_departments(token):
departments = {}
root = _post(token, 'topapi/v2/department/get', {'dept_id': 1, 'language': 'zh_CN'}).get('result') or {}
departments[1] = {
'dept_id': 1,
'parent_id': 0,
'name': str(root.get('name') or '企业组织架构'),
'sort_order': int(root.get('order') or 0),
}
stack = [1]
while stack:
parent_id = stack.pop()
result = _post(token, 'topapi/v2/department/listsub', {'dept_id': parent_id, 'language': 'zh_CN'})
for row in result.get('result') or []:
dept_id = int(row.get('dept_id') or 0)
if not dept_id or dept_id in departments:
continue
departments[dept_id] = {
'dept_id': dept_id,
'parent_id': int(row.get('parent_id') or parent_id),
'name': str(row.get('name') or ''),
'sort_order': int(row.get('order') or 0),
}
stack.append(dept_id)
return departments
def _fetch_userids(token, department_ids):
userids = set()
for dept_id in department_ids:
cursor = 0
while True:
result = _post(token, 'topapi/user/listsimple', {
'dept_id': dept_id,
'cursor': cursor,
'size': 100,
'contain_access_limit': False,
'language': 'zh_CN',
}).get('result') or {}
for row in result.get('list') or []:
userid = str(row.get('userid') or '').strip()
if userid:
userids.add(userid)
if not result.get('has_more'):
break
cursor = int(result.get('next_cursor') or 0)
return userids
def _fetch_users(token, userids):
users = {}
for userid in sorted(userids):
detail = _post(token, 'topapi/v2/user/get', {'userid': userid, 'language': 'zh_CN'}).get('result') or {}
detail['userid'] = userid
users[userid] = detail
return users
def _dashboard_user_matches(conn):
with conn.cursor() as cursor:
cursor.execute("SELECT id, real_name, phone FROM dashboard_users")
rows = cursor.fetchall()
by_phone = {}
by_name = {}
for row in rows:
phone = str(row.get('phone') or '').strip()
name = str(row.get('real_name') or '').strip()
if phone:
by_phone.setdefault(phone, []).append(int(row['id']))
if name:
by_name.setdefault(name, []).append(int(row['id']))
return by_phone, by_name
def _match_dashboard_user(detail, by_phone, by_name):
mobile = str(detail.get('mobile') or '').strip()
name = str(detail.get('name') or '').strip()
if mobile and len(by_phone.get(mobile, [])) == 1:
return by_phone[mobile][0]
if name and len(by_name.get(name, [])) == 1:
return by_name[name][0]
return None
def sync_organization(trigger_type='manual'):
"""全量同步钉钉部门和人员;不自动修改 dashboard_users。"""
ensure_organization_schema()
lock_conn = get_db()
run_id = None
try:
with lock_conn.cursor() as cursor:
cursor.execute("SELECT GET_LOCK('dy_dingtalk_org_sync', 0) AS acquired")
if not int((cursor.fetchone() or {}).get('acquired') or 0):
raise RuntimeError('组织架构同步正在执行,请稍后再试')
started_at = datetime.now()
run_id = query(
"INSERT INTO dingtalk_org_sync_runs(trigger_type,status,started_at) VALUES (%s,'running',%s)",
(trigger_type, started_at),
)
config = load_dingtalk_config()
token = _access_token(config)
departments = _fetch_departments(token)
userids = _fetch_userids(token, departments.keys())
users = _fetch_users(token, userids)
synced_at = datetime.now()
conn = get_db()
try:
by_phone, by_name = _dashboard_user_matches(conn)
matched_count = 0
with conn.cursor() as cursor:
cursor.execute("UPDATE dingtalk_org_departments SET active=0")
cursor.execute("UPDATE dingtalk_org_users SET active=0")
for department in departments.values():
cursor.execute(
"""INSERT INTO dingtalk_org_departments
(dept_id,parent_id,name,sort_order,active,synced_at)
VALUES (%s,%s,%s,%s,1,%s)
ON DUPLICATE KEY UPDATE parent_id=VALUES(parent_id),name=VALUES(name),
sort_order=VALUES(sort_order),active=1,synced_at=VALUES(synced_at)""",
(department['dept_id'], department['parent_id'], department['name'],
department['sort_order'], synced_at),
)
for userid, detail in users.items():
matched_id = _match_dashboard_user(detail, by_phone, by_name)
if matched_id:
matched_count += 1
cursor.execute(
"""INSERT INTO dingtalk_org_users
(userid,unionid,name,mobile,title,job_number,avatar,active,is_admin,is_boss,
hired_date,matched_dashboard_user_id,synced_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE unionid=VALUES(unionid),name=VALUES(name),
mobile=VALUES(mobile),title=VALUES(title),job_number=VALUES(job_number),
avatar=VALUES(avatar),active=VALUES(active),is_admin=VALUES(is_admin),
is_boss=VALUES(is_boss),hired_date=VALUES(hired_date),
matched_dashboard_user_id=VALUES(matched_dashboard_user_id),synced_at=VALUES(synced_at)""",
(userid, str(detail.get('unionid') or ''), str(detail.get('name') or ''),
str(detail.get('mobile') or ''), str(detail.get('title') or ''),
str(detail.get('job_number') or ''), str(detail.get('avatar') or ''),
1 if detail.get('active', True) else 0, 1 if detail.get('admin') else 0,
1 if detail.get('boss') else 0, detail.get('hired_date'), matched_id, synced_at),
)
cursor.execute("DELETE FROM dingtalk_org_memberships")
for userid, detail in users.items():
leader_map = {int(x.get('dept_id') or 0): bool(x.get('leader'))
for x in detail.get('leader_in_dept') or []}
order_map = {int(x.get('dept_id') or 0): int(x.get('order') or 0)
for x in detail.get('dept_order_list') or []}
for dept_id in detail.get('dept_id_list') or []:
dept_id = int(dept_id or 0)
if dept_id not in departments:
continue
cursor.execute(
"INSERT INTO dingtalk_org_memberships(dept_id,userid,is_leader,dept_order) VALUES (%s,%s,%s,%s)",
(dept_id, userid, 1 if leader_map.get(dept_id) else 0, order_map.get(dept_id, 0)),
)
cursor.execute(
"""UPDATE dingtalk_org_sync_runs SET status='success',department_count=%s,
user_count=%s,matched_count=%s,message=%s,finished_at=%s WHERE id=%s""",
(len(departments), len(users), matched_count, '同步成功', synced_at, run_id),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
return {
'run_id': run_id,
'department_count': len(departments),
'user_count': len(users),
'matched_count': matched_count,
'synced_at': synced_at.strftime('%Y-%m-%d %H:%M:%S'),
}
except Exception as exc:
if run_id:
try:
query(
"UPDATE dingtalk_org_sync_runs SET status='failed',message=%s,finished_at=NOW() WHERE id=%s",
(str(exc)[:1000], run_id),
)
except Exception:
pass
raise
finally:
try:
with lock_conn.cursor() as cursor:
cursor.execute("SELECT RELEASE_LOCK('dy_dingtalk_org_sync')")
finally:
lock_conn.close()
def organization_snapshot():
ensure_organization_schema()
departments = query(
"""SELECT dept_id,parent_id,name,sort_order,synced_at
FROM dingtalk_org_departments WHERE active=1 ORDER BY parent_id,sort_order,dept_id"""
)
members = query(
"""SELECT m.dept_id,u.userid,u.name,u.mobile,u.title,u.job_number,u.avatar,
u.active,m.is_leader,m.dept_order,u.matched_dashboard_user_id,
du.username AS matched_username,du.real_name AS matched_real_name
FROM dingtalk_org_memberships m
JOIN dingtalk_org_users u ON u.userid=m.userid AND u.active=1
LEFT JOIN dashboard_users du ON du.id=u.matched_dashboard_user_id
JOIN dingtalk_org_departments d ON d.dept_id=m.dept_id AND d.active=1
ORDER BY m.dept_id,m.is_leader DESC,m.dept_order,u.name"""
)
last_runs = query(
"""SELECT id,trigger_type,status,department_count,user_count,matched_count,message,
started_at,finished_at FROM dingtalk_org_sync_runs ORDER BY id DESC LIMIT 1"""
)
for collection in (departments, members, last_runs):
for row in collection:
for key, value in list(row.items()):
if isinstance(value, datetime):
row[key] = value.strftime('%Y-%m-%d %H:%M:%S')
return {'departments': departments, 'members': members, 'last_sync': last_runs[0] if last_runs else None}