prepare($sql); $st->execute($params); return $st->fetchAll(); } function db_one($sql, $params = array()) { $st = db()->prepare($sql); $st->execute($params); $row = $st->fetch(); return $row === false ? null : $row; } function db_val($sql, $params = array()) { $st = db()->prepare($sql); $st->execute($params); return $st->fetchColumn(); } function db_run($sql, $params = array()) { $st = db()->prepare($sql); $st->execute($params); return $st; } function like_param($q) { return '%' . $q . '%'; } /* ============================================================ * 用户 * ============================================================ */ function user_find_by_name($username) { return db_one('SELECT * FROM users WHERE username = ?', array($username)); } function user_find_by_id($id) { return db_one('SELECT * FROM users WHERE id = ?', array($id)); } function user_name_map() { $map = array(); foreach (db_all('SELECT id, username FROM users') as $r) { $map[$r['id']] = $r['username']; } return $map; } function user_search($q, $limit, $offset) { $limit = (int) $limit; $offset = (int) $offset; if ($q === '') { return db_all('SELECT * FROM users ORDER BY created_at DESC, username ASC LIMIT ' . $limit . ' OFFSET ' . $offset); } $p = like_param($q); return db_all( 'SELECT * FROM users WHERE username LIKE ? OR role LIKE ? ORDER BY created_at DESC, username ASC LIMIT ' . $limit . ' OFFSET ' . $offset, array($p, $p) ); } function user_count($q) { if ($q === '') { return (int) db_val('SELECT COUNT(*) FROM users'); } $p = like_param($q); return (int) db_val('SELECT COUNT(*) FROM users WHERE username LIKE ? OR role LIKE ?', array($p, $p)); } function user_insert($data) { db_run( 'INSERT INTO users (id, username, password_hash, role, disabled, must_change_password, protected, created_at, last_login_at, login_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', array( $data['id'], $data['username'], $data['password_hash'], isset($data['role']) ? $data['role'] : 'user', !empty($data['disabled']) ? 1 : 0, !empty($data['must_change_password']) ? 1 : 0, !empty($data['protected']) ? 1 : 0, isset($data['created_at']) ? $data['created_at'] : now_iso(), isset($data['last_login_at']) ? $data['last_login_at'] : null, isset($data['login_count']) ? (int) $data['login_count'] : 0, ) ); return $data['id']; } function user_update($id, $fields) { $allowed = array('username', 'password_hash', 'role', 'disabled', 'must_change_password'); $sets = array(); $params = array(); foreach ($allowed as $f) { if (array_key_exists($f, $fields)) { $sets[] = $f . ' = ?'; $params[] = $fields[$f]; } } if (!$sets) { return false; } $params[] = $id; db_run('UPDATE users SET ' . implode(', ', $sets) . ' WHERE id = ?', $params); return true; } function user_delete($id) { db_run('DELETE FROM users WHERE id = ?', array($id)); db_run('DELETE FROM sessions WHERE user_id = ?', array($id)); } function user_touch_login($id) { db_run('UPDATE users SET last_login_at = ?, login_count = login_count + 1 WHERE id = ?', array(now_iso(), $id)); } /* ============================================================ * 用户分组 * ============================================================ */ function group_list() { return db_all('SELECT * FROM user_groups ORDER BY created_at ASC, name ASC'); } function group_find($id) { return db_one('SELECT * FROM user_groups WHERE id = ?', array($id)); } function group_find_by_name($name) { return db_one('SELECT * FROM user_groups WHERE name = ?', array($name)); } function group_insert($data) { db_run( 'INSERT INTO user_groups (id, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)', array( $data['id'], $data['name'], isset($data['description']) ? $data['description'] : '', isset($data['created_at']) ? $data['created_at'] : now_iso(), isset($data['updated_at']) ? $data['updated_at'] : now_iso(), ) ); return $data['id']; } function group_update($id, $fields) { $allowed = array('name', 'description'); $sets = array(); $params = array(); foreach ($allowed as $f) { if (array_key_exists($f, $fields)) { $sets[] = $f . ' = ?'; $params[] = $fields[$f]; } } if (!$sets) { return false; } $sets[] = 'updated_at = ?'; $params[] = now_iso(); $params[] = $id; db_run('UPDATE user_groups SET ' . implode(', ', $sets) . ' WHERE id = ?', $params); return true; } function group_delete($id) { db_run('DELETE FROM user_groups WHERE id = ?', array($id)); db_run('DELETE FROM user_group_members WHERE group_id = ?', array($id)); db_run("DELETE FROM topo_shares WHERE target_type = 'group' AND target_id = ?", array($id)); } function group_members($gid) { return db_all( 'SELECT u.id, u.username FROM user_group_members m JOIN users u ON u.id = m.user_id WHERE m.group_id = ? ORDER BY u.username ASC', array($gid) ); } function group_member_ids($gid) { $out = array(); foreach (db_all('SELECT user_id FROM user_group_members WHERE group_id = ?', array($gid)) as $r) { $out[] = $r['user_id']; } return $out; } function group_set_members($gid, $userIds) { db_run('DELETE FROM user_group_members WHERE group_id = ?', array($gid)); $seen = array(); foreach ((array) $userIds as $uid) { $uid = (string) $uid; if ($uid === '' || isset($seen[$uid])) { continue; } $seen[$uid] = true; db_run( 'INSERT INTO user_group_members (group_id, user_id, created_at) VALUES (?, ?, ?)', array($gid, $uid, now_iso()) ); } } function group_ids_of_user($uid) { $out = array(); foreach (db_all('SELECT group_id FROM user_group_members WHERE user_id = ?', array($uid)) as $r) { $out[] = $r['group_id']; } return $out; } function group_member_counts() { $map = array(); foreach (db_all('SELECT group_id, COUNT(*) AS c FROM user_group_members GROUP BY group_id') as $r) { $map[$r['group_id']] = (int) $r['c']; } return $map; } /* ============================================================ * 会话 * ============================================================ */ function session_create($token, $userId, $expires, $ip, $ua) { db_run( 'INSERT INTO sessions (token, user_id, created_at, expires_at, ip, user_agent) VALUES (?, ?, ?, ?, ?, ?)', array($token, $userId, now_iso(), (int) $expires, $ip, $ua) ); } function session_find($token) { return db_one('SELECT * FROM sessions WHERE token = ?', array($token)); } function session_delete($token) { db_run('DELETE FROM sessions WHERE token = ?', array($token)); } function session_delete_by_user($userId) { db_run('DELETE FROM sessions WHERE user_id = ?', array($userId)); } function session_purge_expired($now) { db_run('DELETE FROM sessions WHERE expires_at < ?', array((int) $now)); } /* ============================================================ * 拓扑 * ============================================================ */ function topo_find($id) { return db_one('SELECT * FROM topologies WHERE id = ?', array($id)); } function topo_list_for_user($user) { /* 主应用 / 用户后台返回:本人创建的 + 公开的 + 通过成员/分组共享且已开启共享模式的。 全部拓扑(含他人私有)只能在管理后台查看,见 topo_admin_search()。 */ return db_all( "SELECT DISTINCT t.* FROM topologies t LEFT JOIN topo_shares s ON s.topo_id = t.id WHERE t.owner_id = ? OR t.visibility = 'public' OR (t.share_mode = 1 AND ( (s.target_type = 'user' AND s.target_id = ?) OR (s.target_type = 'group' AND s.target_id IN ( SELECT group_id FROM user_group_members WHERE user_id = ? )) )) ORDER BY t.updated_at DESC", array($user['id'], $user['id'], $user['id']) ); } function topo_admin_search($q, $ownerId, $limit, $offset) { $limit = (int) $limit; $offset = (int) $offset; $where = array(); $params = array(); if ($q !== '') { $where[] = 'name LIKE ?'; $params[] = like_param($q); } if ($ownerId !== '') { $where[] = 'owner_id = ?'; $params[] = $ownerId; } $sql = 'SELECT * FROM topologies'; if ($where) { $sql .= ' WHERE ' . implode(' AND ', $where); } $sql .= ' ORDER BY updated_at DESC LIMIT ' . $limit . ' OFFSET ' . $offset; return db_all($sql, $params); } function topo_admin_count($q, $ownerId) { $where = array(); $params = array(); if ($q !== '') { $where[] = 'name LIKE ?'; $params[] = like_param($q); } if ($ownerId !== '') { $where[] = 'owner_id = ?'; $params[] = $ownerId; } $sql = 'SELECT COUNT(*) FROM topologies'; if ($where) { $sql .= ' WHERE ' . implode(' AND ', $where); } return (int) db_val($sql, $params); } function topo_recent($limit) { $limit = (int) $limit; return db_all('SELECT * FROM topologies ORDER BY updated_at DESC LIMIT ' . $limit); } /* 导出用:根据 id 列表返回完整行;ids 为空时返回全部 */ function topo_export_rows($ids) { if (empty($ids)) { return db_all('SELECT * FROM topologies ORDER BY updated_at DESC'); } $ph = implode(',', array_fill(0, count($ids), '?')); return db_all('SELECT * FROM topologies WHERE id IN (' . $ph . ') ORDER BY updated_at DESC', $ids); } function topo_insert($t) { db_run( 'INSERT INTO topologies (id, name, owner_id, visibility, data, node_count, edge_count, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', array( $t['id'], $t['name'], $t['owner_id'], isset($t['visibility']) ? $t['visibility'] : 'private', isset($t['data']) ? $t['data'] : '{"nodes":[],"edges":[]}', isset($t['node_count']) ? (int) $t['node_count'] : 0, isset($t['edge_count']) ? (int) $t['edge_count'] : 0, isset($t['created_at']) ? $t['created_at'] : now_iso(), isset($t['updated_at']) ? $t['updated_at'] : now_iso(), ) ); return $t['id']; } function topo_update($id, $data, $nodeCount, $edgeCount, $updatedAt, $name = null) { if ($name !== null && $name !== '') { db_run( 'UPDATE topologies SET data = ?, node_count = ?, edge_count = ?, updated_at = ?, rev = rev + 1, name = ? WHERE id = ?', array($data, (int) $nodeCount, (int) $edgeCount, $updatedAt, $name, $id) ); } else { db_run( 'UPDATE topologies SET data = ?, node_count = ?, edge_count = ?, updated_at = ?, rev = rev + 1 WHERE id = ?', array($data, (int) $nodeCount, (int) $edgeCount, $updatedAt, $id) ); } } function topo_delete($id) { db_run('DELETE FROM topologies WHERE id = ?', array($id)); } function topo_set_visibility($id, $vis, $updatedAt) { db_run('UPDATE topologies SET visibility = ?, updated_at = ?, rev = rev + 1 WHERE id = ?', array($vis, $updatedAt, $id)); } function topo_set_name($id, $name, $updatedAt) { db_run('UPDATE topologies SET name = ?, updated_at = ?, rev = rev + 1 WHERE id = ?', array($name, $updatedAt, $id)); } function topo_set_share_mode($id, $on, $updatedAt) { db_run('UPDATE topologies SET share_mode = ?, updated_at = ?, rev = rev + 1 WHERE id = ?', array($on ? 1 : 0, $updatedAt, $id)); } /* ============================================================ * 拓扑共享 * ============================================================ */ function share_list_by_topo($topoId) { return db_all('SELECT * FROM topo_shares WHERE topo_id = ? ORDER BY created_at ASC', array($topoId)); } /** * 整体替换某拓扑的共享列表(事务) */ function share_replace($topoId, $rows, $by) { $pdo = db(); $pdo->beginTransaction(); try { db_run('DELETE FROM topo_shares WHERE topo_id = ?', array($topoId)); foreach ((array) $rows as $r) { db_run( 'INSERT INTO topo_shares (id, topo_id, target_type, target_id, permission, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', array( gen_id('s'), $topoId, $r['target_type'], $r['target_id'], $r['permission'], $by, now_iso(), ) ); } $pdo->commit(); } catch (Exception $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $e; } } /** * 计算用户对拓扑的有效权限:owner | edit | view | none */ function topo_permission_for($row, $user) { if (!$row || !$user) { return 'none'; } if (isset($user['role']) && $user['role'] === 'admin') { return 'owner'; } if ($row['owner_id'] === $user['id']) { return 'owner'; } $best = 'none'; if (!empty($row['share_mode'])) { $gids = group_ids_of_user($user['id']); foreach (share_list_by_topo($row['id']) as $s) { $hit = false; if ($s['target_type'] === 'user' && $s['target_id'] === $user['id']) { $hit = true; } elseif ($s['target_type'] === 'group' && in_array($s['target_id'], $gids, true)) { $hit = true; } if (!$hit) { continue; } if ($s['permission'] === 'edit') { return 'edit'; } if ($s['permission'] === 'view') { $best = 'view'; } } } if ($best === 'view') { return 'view'; } if ($row['visibility'] === 'public') { return 'view'; } return 'none'; } /* ============================================================ * 在线状态(协同) * ============================================================ */ function presence_touch($topoId, $user) { $now = time(); $exists = db_one('SELECT topo_id FROM topo_presence WHERE topo_id = ? AND user_id = ?', array($topoId, $user['id'])); if ($exists) { db_run( 'UPDATE topo_presence SET username = ?, last_seen = ? WHERE topo_id = ? AND user_id = ?', array($user['username'], $now, $topoId, $user['id']) ); } else { db_run( 'INSERT INTO topo_presence (topo_id, user_id, username, last_seen) VALUES (?, ?, ?, ?)', array($topoId, $user['id'], $user['username'], $now) ); } } function presence_list($topoId, $activeWithin) { $cut = time() - (int) $activeWithin; return db_all( 'SELECT user_id, username, last_seen FROM topo_presence WHERE topo_id = ? AND last_seen >= ? ORDER BY last_seen DESC', array($topoId, $cut) ); } function presence_remove($topoId, $userId) { db_run('DELETE FROM topo_presence WHERE topo_id = ? AND user_id = ?', array($topoId, $userId)); } function presence_purge($beforeTs) { db_run('DELETE FROM topo_presence WHERE last_seen < ?', array((int) $beforeTs)); } function topo_rev($id) { return (int) db_val('SELECT rev FROM topologies WHERE id = ?', array($id)); } /* ============================================================ * 操作日志 * ============================================================ */ function log_add($userId, $username, $action, $target, $detail, $ip) { db_run( 'INSERT INTO activity_logs (user_id, username, action, target, detail, ip, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', array($userId, $username, $action, $target, $detail, $ip, now_iso()) ); } function log_search($q, $limit, $offset) { $limit = (int) $limit; $offset = (int) $offset; if ($q === '') { return db_all('SELECT * FROM activity_logs ORDER BY id DESC LIMIT ' . $limit . ' OFFSET ' . $offset); } $p = like_param($q); return db_all( 'SELECT * FROM activity_logs WHERE username LIKE ? OR action LIKE ? OR target LIKE ? OR detail LIKE ? ORDER BY id DESC LIMIT ' . $limit . ' OFFSET ' . $offset, array($p, $p, $p, $p) ); } function log_count($q) { if ($q === '') { return (int) db_val('SELECT COUNT(*) FROM activity_logs'); } $p = like_param($q); return (int) db_val( 'SELECT COUNT(*) FROM activity_logs WHERE username LIKE ? OR action LIKE ? OR target LIKE ? OR detail LIKE ?', array($p, $p, $p, $p) ); } function log_recent($limit) { $limit = (int) $limit; return db_all('SELECT * FROM activity_logs ORDER BY id DESC LIMIT ' . $limit); } /* ============================================================ * 统计 * ============================================================ */ function stats_overview() { $today = date('Y-m-d') . '%'; return array( 'userCount' => (int) db_val('SELECT COUNT(*) FROM users'), 'adminCount' => (int) db_val("SELECT COUNT(*) FROM users WHERE role = 'admin' AND disabled = 0"), 'disabledCount' => (int) db_val('SELECT COUNT(*) FROM users WHERE disabled = 1'), 'topoCount' => (int) db_val('SELECT COUNT(*) FROM topologies'), 'publicCount' => (int) db_val("SELECT COUNT(*) FROM topologies WHERE visibility = 'public'"), 'todayTopoCount' => (int) db_val('SELECT COUNT(*) FROM topologies WHERE created_at LIKE ?', array($today)), 'todayLoginCount' => (int) db_val("SELECT COUNT(*) FROM activity_logs WHERE action = 'login' AND created_at LIKE ?", array($today)), 'todayLogCount' => (int) db_val('SELECT COUNT(*) FROM activity_logs WHERE created_at LIKE ?', array($today)), ); } /* ============================================================ * 站点设置(key-value) * ============================================================ */ function setting_get($key, $default = null) { $row = db_one('SELECT sval FROM settings WHERE skey = ?', array($key)); return $row === null ? $default : $row['sval']; } function setting_set($key, $val) { $now = now_iso(); if (db_one('SELECT skey FROM settings WHERE skey = ?', array($key)) !== null) { db_run('UPDATE settings SET sval = ?, updated_at = ? WHERE skey = ?', array($val, $now, $key)); } else { db_run('INSERT INTO settings (skey, sval, updated_at) VALUES (?, ?, ?)', array($key, $val, $now)); } } function settings_all() { $out = array(); foreach (db_all('SELECT skey, sval FROM settings') as $r) { $out[$r['skey']] = $r['sval']; } return $out; } /* ============================================================ * 旧 JSON 数据迁移 * ============================================================ */ function migrate_legacy_json() { $dir = data_dir(); $usersFile = $dir . DIRECTORY_SEPARATOR . 'users.json'; $topoIndexFile = $dir . DIRECTORY_SEPARATOR . 'topologies.json'; $topoDir = $dir . DIRECTORY_SEPARATOR . 'topologies'; if (is_file($usersFile) && (int) db_val('SELECT COUNT(*) FROM users') === 0) { $data = json_decode(@file_get_contents($usersFile), true); if (isset($data['users']) && is_array($data['users'])) { foreach ($data['users'] as $u) { if (empty($u['id']) || empty($u['username'])) { continue; } user_insert(array( 'id' => $u['id'], 'username' => $u['username'], 'password_hash' => isset($u['passwordHash']) ? $u['passwordHash'] : '', 'role' => isset($u['role']) ? $u['role'] : 'user', 'disabled' => !empty($u['disabled']) ? 1 : 0, 'must_change_password' => !empty($u['mustChangePassword']) ? 1 : 0, 'created_at' => isset($u['createdAt']) ? $u['createdAt'] : now_iso(), )); } } @rename($usersFile, $usersFile . '.bak'); } if (is_file($topoIndexFile)) { $idx = json_decode(@file_get_contents($topoIndexFile), true); if (isset($idx['topologies']) && is_array($idx['topologies'])) { foreach ($idx['topologies'] as $t) { if (empty($t['id']) || topo_find($t['id'])) { continue; } $content = array('nodes' => array(), 'edges' => array()); $cf = $topoDir . DIRECTORY_SEPARATOR . $t['id'] . '.json'; if (is_file($cf)) { $c = json_decode(@file_get_contents($cf), true); if (is_array($c)) { $content = $c; } } topo_insert(array( 'id' => $t['id'], 'name' => isset($t['name']) ? $t['name'] : '未命名', 'owner_id' => isset($t['ownerId']) ? $t['ownerId'] : '', 'visibility' => isset($t['visibility']) ? $t['visibility'] : 'private', 'data' => json_encode($content, JSON_UNESCAPED_UNICODE), 'node_count' => isset($content['nodes']) ? count($content['nodes']) : 0, 'edge_count' => isset($content['edges']) ? count($content['edges']) : 0, 'created_at' => isset($t['createdAt']) ? $t['createdAt'] : now_iso(), 'updated_at' => isset($t['updatedAt']) ? $t['updatedAt'] : now_iso(), )); } } @rename($topoIndexFile, $topoIndexFile . '.bak'); } } /** * 首次初始化:迁移旧数据 + 创建默认管理员 */ function ensure_seed() { migrate_legacy_json(); if ((int) db_val('SELECT COUNT(*) FROM users') > 0) { protect_configured_admin(); return; } $cfg = app_config(); $admin = isset($cfg['default_admin']) ? $cfg['default_admin'] : array('username' => 'admin', 'password' => 'admin123'); user_insert(array( 'id' => gen_id('u'), 'username' => $admin['username'], 'password_hash' => password_hash($admin['password'], PASSWORD_DEFAULT), 'role' => 'admin', 'disabled' => 0, 'must_change_password' => 1, 'created_at' => now_iso(), 'protected' => 1, )); } /** * 默认管理员(配置文件中的 default_admin)始终置为受保护账号,不可被其他用户改动 */ function protect_configured_admin() { $cfg = app_config(); $name = isset($cfg['default_admin']['username']) ? $cfg['default_admin']['username'] : 'admin'; if ($name !== '' && $name !== null) { db_run('UPDATE users SET protected = 1 WHERE username = ?', array($name)); } }