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