
简介这是一套基于PHP开发的中小型域名代管系统源码包内置dmadmin管理后台适合需要快速搭建域名注册、解析、续费管理或代售业务的开发者、站长及PHP初中级学习者使用。系统围绕域名的批量录入、状态监控、客户管理及后台配置等常见场景设计可帮助用户减少重复手工操作实现域名资产的集中化运维。资源包共20个文件压缩后仅1.86MB主要由9个PHP功能脚本、3个数据库表结构文件、前端JS/CSS样式、htm模板页面以及jpg、ttf等静态资源组成同时附带了db数据库文件与txt说明文档结构清晰便于本地部署和二次开发。当前已有206人学习下载适合希望参考完整后台代码逻辑、理解域名管理业务流程或基于此进行功能扩展的PHP开发者。1. 自建域名代管系统的边界与时机名下域名超过几十个之后第三方域名平台的批量操作、权限分配、到期提醒都会变得束手束脚要么按解析条数额外收费要么开放 API 的配额根本不够跑定时巡检。dmadmin 这套 PHP 写的域名代管系统就是把域名列表、解析记录、操作日志和管理员权限收拢到一个后台程序里用浏览器完成日常代管工作。它不需要重型依赖LAMP 或 LNMP 环境都能直接跑适合 IDC 运维、企业 IT、以及习惯自己掌握数据的个人站长。接下来从它的系统骨架、数据表设计开始拆再部署到 Nginx 上走一遍域名记录添加与批量导入流程最后把常见的报错和加固手段整理清楚。2. dmadmin 的系统骨架与数据模型2.1 入口文件没有框架的前台控制器dmadmin 没有引入 Laravel 或 ThinkPHP 这类重量级框架入口文件直接用 PHP 原生的$_GET参数做模块分发。这样做的优势是部署简单、文件结构一眼能看穿适合内部管理工具。常见做法是约定m参数代表模块a参数代表动作开头加上白名单校验防止任意文件包含。?php define(IN_SYS, true); require_once dirname(__FILE__) . /config.php; require_once ROOT_PATH . includes/db.php; $module isset($_GET[m]) ? trim($_GET[m]) : dashboard; $action isset($_GET[a]) ? trim($_GET[a]) : index; $module preg_replace(/[^a-z_]/i, , $module); $action preg_replace(/[^a-z_]/i, , $action); $allow [dashboard, domain, record, user, log, setting]; if (!in_array($module, $allow, true)) { exit(module not allowed); } $file ROOT_PATH . modules/ . $module . .php; if (is_file($file)) { require_once $file; $class ucfirst($module); if (class_exists($class)) { $obj new $class(); if (method_exists($obj, $action)) { $obj-$action(); } else { exit(action not found); } } }这段代码的关键在于preg_replace把模块名和动作名里的非法字符全部剥掉再配合$allow白名单避免通过../之类的方式加载任意文件。method_exists的检查保证只调用类里真实存在的方法防止外部传入恶意的方法名。这段分发逻辑是所有后台页面的入口后续所有模块都沿用这一套规则。2.2 核心数据表设计域名代管系统最核心的数据不是域名本身而是“域名和解析记录”的关系。dmadmin 使用一张主表存域名一张子表存解析记录两张表通过domain_id关联。先看整库的表清单表名职责dm_domain域名主表保存域名、注册人、到期时间dm_dns_record解析记录A / AAAA / CNAME / MX / TXT / NSdm_user后台账号角色与分组dm_operation_log每一步操作的审计日志dm_api_config第三方 DNS API 的密钥与平台配置这个拆分方式很直接域名是静态资料解析记录是高频变动的业务数据两者拆开之后批量修改解析时可以只操作子表不用反复更新主表索引。2.2.1 域名主表与解析记录表建表语句我一般会加上唯一索引和status字段方便后续做软删除或暂停解析。CREATE TABLE dm_domain ( domain_id int(11) unsigned NOT NULL AUTO_INCREMENT, domain_name varchar(255) NOT NULL, user_id int(11) NOT NULL DEFAULT 0, reg_time datetime DEFAULT NULL, expire_time datetime DEFAULT NULL, status tinyint(1) NOT NULL DEFAULT 1, dns_provider varchar(50) NOT NULL DEFAULT local, PRIMARY KEY (domain_id), UNIQUE KEY uk_domain (domain_name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE dm_dns_record ( record_id int(11) unsigned NOT NULL AUTO_INCREMENT, domain_id int(11) NOT NULL, name varchar(255) NOT NULL DEFAULT , type varchar(20) NOT NULL, value varchar(500) NOT NULL, ttl int(11) NOT NULL DEFAULT 600, mx_priority int(11) DEFAULT 0, status tinyint(4) NOT NULL DEFAULT 1, PRIMARY KEY (record_id), KEY idx_domain_id (domain_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;dm_domain里的domain_name必须带UNIQUE KEY否则同一个域名会被重复录入后面跑 DNS 巡检时会重复请求。dm_dns_record的name字段存的是相对主机记录比如www、、mailvalue存解析目标type记录解析类型ttl默认 600 秒。mx_priority只在typeMX时生效存优先级数值。2.2.2 账号、日志与 API 配置表账号表不需要设计得过于复杂但要预留角色字段方便以后做权限区分。日志表则必须记录“谁在什么时间改了什么”这是代管系统被多人使用时最重要的审计依据。CREATE TABLE dm_user ( user_id int(11) unsigned NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password varchar(255) NOT NULL, role varchar(20) NOT NULL DEFAULT operator, status tinyint(4) NOT NULL DEFAULT 1, PRIMARY KEY (user_id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE dm_operation_log ( log_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, user_id int(11) NOT NULL DEFAULT 0, action varchar(100) NOT NULL, detail text, ip varchar(45) NOT NULL DEFAULT , created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (log_id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;action字段建议统一使用domain.add、record.update这样的命名后面查日志用LIKE record.%就能快速过滤。日志表最后加上created_at默认时间戳避免业务代码里反复写时间函数。2.3 数据库连接与 SQL 注入防护老 PHP 系统最容易被$_GET或$_POST拼进 SQLdmadmin 的做法是把 SQL 操作封装成公共函数强制走预处理。核心连接配置写在config.phpdefine(DB_HOST, 127.0.0.1); define(DB_PORT, 3306); define(DB_USER, dmadmin); define(DB_PASS, change-me); define(DB_NAME, dmadmin); define(DB_CHARSET, utf8mb4);封装在includes/db.php的查询函数是全系统查询入口function db_query($sql, $params []) { $conn new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT); $conn-set_charset(DB_CHARSET); $stmt $conn-prepare($sql); if ($params) { $types ; foreach ($params as $p) { $types . is_int($p) ? i : s; } $stmt-bind_param($types, ...$params); } $stmt-execute(); $result $stmt-get_result(); $stmt-close(); $conn-close(); return $result; }bind_param的第一个参数由参数类型自动拼成例如[is, $userId, $name]整数用i字符串用s。这样做的好处是数据永远不会被当成 SQL 语法执行能从根上避免注入。代价是每次查询都会新建连接在低并发的后台系统里完全没有问题如果以后要把域名巡检做成高频率任务建议换成 PDO 长连接或把连接对象提升为静态属性复用。3. 环境准备与安装初始化从压缩包到跑起来3.1 PHP 版本与扩展检查这套系统核心语法仍是 PHP 7 时代的写法在 PHP 7.4 上最稳妥8.0/8.1 也兼容但如果你本机是 PHP 8.2 以上需要注意mysqli的默认配置差异。部署前先确认扩展是否齐全php -v php -m | grep -Ei mysqli|curl|openssl|mbstring没有curl扩展会导致第三方 DNS API 对接失败mbstring缺失会让中文域名或备注乱码。Ubuntu 下的补装命令是sudo apt-get update sudo apt-get install -y php-mysql php-curl php-mbstring php-redis systemctl restart php8.1-fpm这里php-redis是为了后面的异步队列做铺垫先装好不亏。3.2 Nginx 与 Apache 的部署配置常见部署方式是把压缩包解压到/var/www/dmadmin绑定一个独立域名或子目录访问。Nginx 配置要同时处理前端控制器和敏感文件拦截server { listen 80; server_name dm.example.com; root /var/www/dmadmin; index index.php; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } location ~* \.(sql|log|bak|conf|ini)$ { deny all; } }try_files把所有非文件请求交给index.php模块参数从$query_string里解析。最后一段location用正则把数据库备份、日志、配置类文件全部拦截防止别人直接下载你留下的install.sql或config.php.bak。如果你习惯用 Apache直接放一个.htaccessRewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^(.*)$ index.php?/$1 [L]两种方式效果一致目的都是让后台只有一个 PHP 入口。3.3 安装向导与初始管理员解压后的目录里会有install/文件夹浏览器访问http://dm.example.com/install/后会进入安装页面步骤通常包括环境检查、数据库参数填写、管理员账号创建。安装程序会把默认数据写入库并在install/下生成一个install.lock文件防止重复安装。如果安装页提示无法创建数据表先检查数据库账号是否有CREATE权限GRANT ALL PRIVILEGES ON dmadmin.* TO dmadminlocalhost; FLUSH PRIVILEGES;安装完成后建议立即把整个install/目录删掉或者改名否则攻击者可以直接绕过后台覆盖管理员数据。这也是很多老源码包最容易忽略的收尾动作。3.4 登录认证流程的现代化改造很多老系统的登录逻辑还在用MD5($password)这类弱哈希放到现在的环境下非常危险。dmadmin 如果仍然使用老逻辑我会在拿到源码后先做一次改造改成 PHP 内置的password_hash和password_verify$hash password_hash($password, PASSWORD_DEFAULT); if (password_verify($password, $hash)) { $_SESSION[uid] $user[user_id]; }PASSWORD_DEFAULT会把哈希算法自动升级到 bcrypt并且每次生成的盐都不一样。改造现有用户时写一段一次性脚本读取旧md5密码再调用password_hash重新生成即可登录处增加一个判断如果当前存储的哈希值是 32 位十六进制则用旧算法校验校验成功后替换成新算法。这样老用户不用重置密码也能平滑升级。4. 域名解析与绑定操作的代码实现4.1 域名格式校验与归属检查后台新增域名时第一步不是写库而是校验域名格式。直接用filter_var不够可靠我会用逐标签检查function checkDomain($domain) { $domain strtolower(trim($domain, .\t\r\n)); if (mb_strlen($domain) 255 || mb_strlen($domain) 4) { return false; } if (preg_match(/^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?)$/, $domain) ! 1) { return false; } return true; }正则要求域名至少两段每一段不允许加减号开头或结尾整体长度限制在 255 字节以内。校验通过之后再查一次dm_domain表的唯一索引重复域名直接提示不做第二次写入。这样能避免后面批量导入时产生脏数据。4.2 解析记录的增删改查实现新增解析记录是最核心的写操作dmadmin 在本地记录模式下代码最终会落到dm_dns_record表的INSERT。封装后的函数如下function localAddRecord(array $record) { $sql INSERT INTO dm_dns_record (domain_id, name, type, value, ttl, status) VALUES (?, ?, ?, ?, ?, 1); return db_query($sql, [ $record[domain_id], $record[name], $record[type], $record[value], $record[ttl] ]); }这里的参数顺序必须和 SQL 里的占位符完全对齐status写死在 SQL 中表示新记录默认启用。ttl如果为空最好在调用前补充默认值600。删除记录的常规做法不是物理删除而是执行UPDATE dm_dns_record SET status 0 WHERE record_id ?这样误删后可以一键恢复。如果要把记录同步到真实的云 DNS 平台做法是在同一业务层里调用一次第三方 API。dmadmin 支持这一层抽象类似function syncToDnsProvider($domain, $record) { $provider createProvider($domain[dns_provider]); $provider-addRecord( $domain[domain_name], $record[type], $record[name], $record[value], $record[ttl] ); }dns_provider字段决定走哪个平台的 API 适配器常见做法是使用阿里云或腾讯云的官方 SDK签名、请求、错误重试都由适配器内部处理核心业务层不感知具体平台差异。4.3 批量导入与异步解析队列4.3.1 CSV 导入的常见坑后台批量导入通常用 CSV 文件。最容易踩的坑是编码错乱和空行导致的中断。导入时要先检测编码遇到UTF-8以外的文件用iconv转码再按行解析if (pathinfo($filename, PATHINFO_EXTENSION) ! csv) { throw new RuntimeException(只支持 CSV 文件); } $handle fopen($filename, r); fgetcsv($handle); // 跳过表头 while (($row fgetcsv($handle)) ! false) { $domain trim($row[0]); $type strtoupper(trim($row[1])); if (!checkDomain($domain) || !in_array($type, [A, AAAA, CNAME, MX, TXT], true)) { continue; } // 逐行写入 } fclose($handle);fgetcsv默认能处理字段里的逗号和引号比手工explode安全得多。逐行写入时如果某一行重复duplicate错误会被唯一索引拦截我一般会在循环里捕获mysqli的错误号遇到 1062 不中断继续下一条。4.3.2 基于 Redis 的解析任务队列几百条解析记录如果全部同步到外部 DNS 平台请求会非常密集很容易触发平台限流。常见的做法是先把导入任务压入 Redis 队列再由命令行或后台定时任务消费把并发摊平。生产端的代码很直接$task [ domain $domain, type $type, name $name, value $value, ttl 600, provider $domainInfo[dns_provider] ]; $redis-lpush(dns_task_queue, json_encode($task));消费端可以做成 CLI 脚本cron_worker.phpwhile ($task $redis-rpop(dns_task_queue)) { $data json_decode($task, true); $provider createProvider($data[provider]); try { $provider-addRecord( $data[domain], $data[type], $data[name], $data[value], $data[ttl] ); } catch (Exception $e) { $redis-lpush(dns_task_failed, $task); } }这里rpop是从列表右侧取出任务配合左侧lpush形成 FIFO 队列。消费失败的任务重新放回dns_task_failed等待手动处理。要注意的是rpop在处理中途一旦进程崩溃任务会丢失生产环境建议改用 Redis Streams 的xreadgroup配合 pending 列表做消费确认。5. 排错与进阶把 dmadmin 变成生产可用的代管后台5.1 常见报错与日志定位后台白屏时先查 PHP 错误日志tail -f /var/log/php8.1-fpm.log tail -f /var/log/nginx/error.log看到类似PHP Fatal error: Uncaught Error: Call to undefined function mysqli_connect()的报错说明扩展没装。另一个高发问题是“页面能打开但任何操作都报 500”这时候打开config.php检查DB_HOST换成127.0.0.1而不是localhost因为 PHP 8.1 下mysqli对localhost走 socket 连接和 MySQL 的 socket 路径不一致就会失败。5.2 安全加固清单拿到这套源码后第一轮改造我会按下面的顺序做删除install/目录或添加install.lock。检查includes/下是否有可直接访问的.php文件确认所有包含文件顶部都有IN_SYS常量判断。给后台登录接口加上 CSRF Token表单提交时校验 Token 是否和 Session 一致。在 Nginx 里拦截可执行文件上传目录防止通过头像上传拿到 webshelllocation ~* /data/(upload|tmp)/.*\.php$ { deny all; }这段配置直接拒绝上传目录下的所有 PHP 执行请求即使攻击者把恶意 PHP 文件改名上传也无法在浏览器里触发执行。.sql、.bak、.log文件同样建议全局拦截已经在第 3 章的 Nginx 配置里完成。5.3 对外接口化改造代管系统最常被外部系统调用的功能是“查询某个域名是否存在、属于哪个用户、有没有解析记录”。改造时可以单独做一个api.php入口不走后台登录态改用 Token 认证$token $headers[X-Api-Key] ?? ; if ($token ! API_SECRET) { http_response_code(401); exit(json_encode([error unauthorized])); } header(Content-Type: application/json; charsetutf-8); $domain $_GET[domain] ?? ; if (!checkDomain($domain)) { exit(json_encode([error invalid domain])); } $sql SELECT d.domain_name, r.name, r.type, r.value, r.ttl FROM dm_domain d LEFT JOIN dm_dns_record r ON r.domain_id d.domain_id AND r.status 1 WHERE d.domain_name ?; $result db_query($sql, [$domain]); $rows []; while ($row $result-fetch_assoc()) { $rows[] $row; } echo json_encode([code 0, data $rows]);X-Api-Key从请求头读取密钥放在单独的配置文件中不要写进公共的config.php。返回结构固定为code data外部系统只需要解析 JSON 就能拿到解析记录列表。这套做法同样适用于把dmadmin接入内部的 CMDB 或工单系统域名状态查询、解析记录同步都可以复用同一个接口。本文还有配套的精品资源点击获取