341 lines
15 KiB
Python
341 lines
15 KiB
Python
"""钉钉组织架构只读同步服务。"""
|
||
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}
|