域名管理

This commit is contained in:
root
2026-06-02 00:32:05 +08:00
parent f2ec634888
commit 4aec0b4fce
20 changed files with 1879 additions and 7 deletions
+26
View File
@@ -0,0 +1,26 @@
-- 域名管理模块表结构
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS `fa_domain`;
CREATE TABLE `fa_domain` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`admin_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '管理员ID',
`domain` varchar(255) NOT NULL DEFAULT '' COMMENT '根域名',
`full_url` varchar(512) NOT NULL DEFAULT '' COMMENT '完整链接',
`zone_id` varchar(64) NOT NULL DEFAULT '' COMMENT 'Cloudflare Zone ID',
`nameservers` text COMMENT 'CF分配的NS(JSON)',
`zone_status` enum('pending','active','failed') NOT NULL DEFAULT 'pending' COMMENT 'Zone状态',
`ns_status` enum('pending','verified','failed') NOT NULL DEFAULT 'pending' COMMENT 'NS状态',
`dns_status` enum('pending','created','failed') NOT NULL DEFAULT 'pending' COMMENT 'DNS状态',
`check_time` bigint(16) DEFAULT NULL COMMENT '检测时间',
`check_result` varchar(2000) DEFAULT '' COMMENT '最近检测结果',
`createtime` bigint(16) DEFAULT NULL COMMENT '创建时间',
`updatetime` bigint(16) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `domain` (`domain`),
KEY `admin_id` (`admin_id`),
KEY `zone_status` (`zone_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='域名管理表';
SET FOREIGN_KEY_CHECKS = 1;
View File
+247
View File
@@ -0,0 +1,247 @@
<?php
declare(strict_types=1);
namespace app\admin\controller;
use app\admin\model\Domain as DomainModel;
use app\common\controller\Backend;
use app\common\service\CloudflareService;
use app\common\service\DomainDetectRateLimitService;
use think\Db;
use think\Exception;
use think\exception\PDOException;
use think\exception\ValidateException;
/**
* 域名管理
*
* @icon fa fa-globe
* @remark 域名接入Cloudflare,新增后请在详情查看NS并到注册商修改
*/
class Domain extends Backend
{
/** @var DomainModel */
protected $model = null;
protected $searchFields = 'domain';
protected $dataLimit = 'personal';
protected $modelValidate = true;
protected $modelSceneValidate = true;
public function _initialize()
{
parent::_initialize();
$this->model = new DomainModel();
$this->view->assign('zoneStatusList', $this->model->getZoneStatusList());
$this->view->assign('nsStatusList', $this->model->getNsStatusList());
$this->view->assign('dnsStatusList', $this->model->getDnsStatusList());
$this->assignconfig('zoneStatusList', $this->model->getZoneStatusList());
$this->assignconfig('nsStatusList', $this->model->getNsStatusList());
$this->assignconfig('dnsStatusList', $this->model->getDnsStatusList());
}
/**
* 禁止编辑
*/
public function edit($ids = null)
{
$this->error(__('Domain cannot be edited after creation'));
}
/**
* 禁止批量操作
*/
public function multi($ids = '')
{
$this->error(__('Invalid parameters'));
}
/**
* 添加域名并创建 Cloudflare Zone
*/
public function add()
{
if (false === $this->request->isPost()) {
return $this->view->fetch();
}
$params = $this->request->post('row/a', []);
if ($params === []) {
$this->error(__('Parameter %s can not be empty', ''));
}
$params = $this->preExcludeFields($params);
$domain = DomainModel::normalizeDomain((string)($params['domain'] ?? ''));
$params['domain'] = $domain;
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
$params[$this->dataLimitField] = $this->auth->id;
}
$result = false;
Db::startTrans();
try {
if ($this->modelValidate) {
$name = str_replace('\\model\\', '\\validate\\', get_class($this->model));
$validate = $this->modelSceneValidate ? $name . '.add' : $name;
$this->model->validateFailException()->validate($validate, $params);
}
if (DomainModel::where('domain', $domain)->find()) {
throw new ValidateException(__('Domain already exists'));
}
$cloudflare = new CloudflareService();
$zone = $cloudflare->createZone($domain);
$params['full_url'] = 'https://' . $domain;
$params['zone_id'] = $zone['zone_id'];
$params['nameservers'] = $zone['name_servers'];
$params['zone_status'] = DomainModel::mapCloudflareZoneStatus($zone['status']);
$params['ns_status'] = 'pending';
$params['dns_status'] = 'pending';
$params['check_result'] = '';
$result = $this->model->allowField(true)->save($params);
Db::commit();
} catch (ValidateException $e) {
Db::rollback();
$this->error($e->getMessage());
} catch (PDOException|Exception $e) {
Db::rollback();
$this->error($e->getMessage());
}
if ($result === false) {
$this->error(__('No rows were inserted'));
}
$this->success(__('Domain submitted, please check NS in detail page and update at registrar'));
}
/**
* 域名详情
*/
public function detail($ids = null)
{
$row = $this->model->get($ids);
if (!$row) {
$this->error(__('No Results were found'));
}
if (!$this->checkDataLimit($row)) {
$this->error(__('You have no permission'));
}
$data = $row->toArray();
$data['nameservers_list'] = $row->getNameserversArray();
$this->view->assign('row', $data);
return $this->view->fetch();
}
/**
* 检测域名状态
*/
public function detect($ids = null)
{
if (!$this->request->isPost()) {
$this->error(__('Invalid parameters'));
}
$row = $this->model->get($ids);
if (!$row) {
$this->error(__('No Results were found'));
}
if (!$this->checkDataLimit($row)) {
$this->error(__('You have no permission'));
}
try {
(new DomainDetectRateLimitService())->assertCanDetect((int)$this->auth->id, (int)$row['id']);
} catch (Exception $e) {
$this->error($e->getMessage());
}
try {
$cloudflare = new CloudflareService();
$detect = $cloudflare->detectDomain($row->toArray());
$row->save([
'zone_status' => $detect['zone_status'],
'ns_status' => $detect['ns_status'],
'dns_status' => $detect['dns_status'],
'check_time' => time(),
'check_result' => $detect['check_result'],
]);
} catch (Exception $e) {
$this->error($e->getMessage());
}
$this->success(__('Detection completed'), null, $row->toArray());
}
/**
* 删除(需输入完整域名二次确认)
*/
public function del($ids = '')
{
if (!$this->request->isPost()) {
$this->error(__('Invalid parameters'));
}
$ids = $ids ?: $this->request->post('ids', '');
$confirmDomain = trim((string)$this->request->post('confirm_domain', ''));
if ($ids === '' || $confirmDomain === '') {
$this->error(__('Please enter the full domain to confirm deletion'));
}
$pk = $this->model->getPk();
$list = $this->model->where($pk, 'in', $ids)->select();
if (!$list || count($list) === 0) {
$this->error(__('No Results were found'));
}
$count = 0;
Db::startTrans();
try {
foreach ($list as $item) {
if (!$this->checkDataLimit($item)) {
throw new Exception(__('You have no permission'));
}
if (DomainModel::normalizeDomain($confirmDomain) !== DomainModel::normalizeDomain((string)$item['domain'])) {
throw new Exception(__('Domain confirmation does not match'));
}
if (!empty($item['zone_id'])) {
$cloudflare = new CloudflareService();
$cloudflare->deleteZone((string)$item['zone_id']);
}
$count += $item->delete();
}
Db::commit();
} catch (PDOException|Exception $e) {
Db::rollback();
$this->error($e->getMessage());
}
if ($count > 0) {
$this->success();
}
$this->error(__('No rows were deleted'));
}
/**
* @param DomainModel $row
*/
protected function checkDataLimit($row): bool
{
if (!$this->dataLimit) {
return true;
}
$adminIds = $this->getDataLimitAdminIds();
if (!is_array($adminIds)) {
return true;
}
return in_array((int)$row[$this->dataLimitField], $adminIds, true);
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
return [
'Domain' => '域名',
'Full_url' => '完整链接',
'Zone_status' => 'Zone状态',
'Ns_status' => 'NameServer状态',
'Dns_status' => 'DNS状态',
'Nameservers' => 'NameServer',
'Check_time' => '检测时间',
'Check_result' => '最近检测结果',
'Createtime' => '创建时间',
'Zone pending' => '待激活',
'Zone active' => '已激活',
'Zone failed' => '异常',
'NS pending' => '待验证',
'NS verified' => '已验证',
'NS failed' => '验证失败',
'DNS pending' => '待创建',
'DNS created' => '已创建',
'DNS failed' => '异常',
'Detect' => '检测',
'Detection completed' => '检测完成',
'Domain input tips title' => '填写说明',
'Domain input tips line1' => '请输入根域名,比如:nihao.com,不要填写 www,且不支持二级子域名。',
'Domain input tips line2' => '不支持中国域名注册商注册的域名,比如阿里云、腾讯云等注册商;也不支持中国域名后缀,包括 cn、com.cn、net.cn、org.cn 等。',
'Domain guide title' => '接入指引',
'Domain guide content' => '提交后,请在详情中查看 Cloudflare 实际分配的 NS,并到域名注册商处修改。',
'Domain submitted, please check NS in detail page and update at registrar' => '域名已提交,请在详情中查看 Cloudflare 分配的 NS,并到注册商处修改',
'Domain cannot be edited after creation' => '域名创建后不可编辑',
'Domain already exists' => '该域名已存在',
'Please enter the full domain to confirm deletion' => '请输入完整域名以确认删除',
'Domain confirmation does not match' => '输入的域名与记录不一致,删除已取消',
'Delete domain confirm prompt' => '请输入完整域名以确认删除',
'Delete domain confirm mismatch' => '域名输入不一致,请重新确认',
];
+189
View File
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace app\admin\model;
use think\Model;
/**
* 域名管理模型
*/
class Domain extends Model
{
/** @var string 中国域名后缀黑名单 */
public const CHINA_SUFFIX_BLACKLIST = [
'cn', 'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn', 'ac.cn', 'mil.cn',
];
protected $name = 'domain';
protected $autoWriteTimestamp = 'integer';
protected $createTime = 'createtime';
protected $updateTime = 'updatetime';
protected $deleteTime = false;
protected $append = [
'zone_status_text',
'ns_status_text',
'dns_status_text',
];
public function getZoneStatusList(): array
{
return [
'pending' => __('Zone pending'),
'active' => __('Zone active'),
'failed' => __('Zone failed'),
];
}
public function getNsStatusList(): array
{
return [
'pending' => __('NS pending'),
'verified' => __('NS verified'),
'failed' => __('NS failed'),
];
}
public function getDnsStatusList(): array
{
return [
'pending' => __('DNS pending'),
'created' => __('DNS created'),
'failed' => __('DNS failed'),
];
}
public function getZoneStatusTextAttr($value, $data): string
{
$value = $value ?: ($data['zone_status'] ?? '');
$list = $this->getZoneStatusList();
return $list[$value] ?? '';
}
public function getNsStatusTextAttr($value, $data): string
{
$value = $value ?: ($data['ns_status'] ?? '');
$list = $this->getNsStatusList();
return $list[$value] ?? '';
}
public function getDnsStatusTextAttr($value, $data): string
{
$value = $value ?: ($data['dns_status'] ?? '');
$list = $this->getDnsStatusList();
return $list[$value] ?? '';
}
public function setNameserversAttr($value): string
{
if (is_array($value)) {
return json_encode(array_values($value), JSON_UNESCAPED_UNICODE);
}
return (string)$value;
}
/**
* @return array<int, string>
*/
public function getNameserversArray(): array
{
$value = $this->getAttr('nameservers');
if (is_array($value)) {
return $value;
}
if (is_string($value) && $value !== '') {
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
return [];
}
public static function normalizeDomain(string $domain): string
{
return strtolower(trim($domain));
}
public static function isValidRootDomain(string $domain): bool
{
$domain = self::normalizeDomain($domain);
if ($domain === '' || strlen($domain) > 253) {
return false;
}
if (strpos($domain, 'www.') === 0) {
return false;
}
if (!preg_match('/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i', $domain)) {
return false;
}
if (self::isChinaSuffix($domain)) {
return false;
}
$label = self::getDomainLabelWithoutSuffix($domain);
if ($label === '' || strpos($label, '.') !== false) {
return false;
}
return true;
}
public static function isChinaSuffix(string $domain): bool
{
$domain = self::normalizeDomain($domain);
foreach (self::CHINA_SUFFIX_BLACKLIST as $suffix) {
if ($domain === $suffix || self::endsWith($domain, '.' . $suffix)) {
return true;
}
}
return false;
}
public static function getMatchedSuffix(string $domain): string
{
$domain = self::normalizeDomain($domain);
$suffixes = array_merge(self::CHINA_SUFFIX_BLACKLIST, ['co.uk', 'com', 'net', 'org', 'io', 'co', 'me', 'info', 'biz']);
usort($suffixes, function ($a, $b) {
return strlen($b) - strlen($a);
});
foreach ($suffixes as $suffix) {
if ($domain === $suffix || self::endsWith($domain, '.' . $suffix)) {
return $suffix;
}
}
return '';
}
public static function getDomainLabelWithoutSuffix(string $domain): string
{
$domain = self::normalizeDomain($domain);
$suffix = self::getMatchedSuffix($domain);
if ($suffix === '') {
$pos = strrpos($domain, '.');
return $pos === false ? $domain : substr($domain, 0, $pos);
}
$suffixWithDot = '.' . $suffix;
if (self::endsWith($domain, $suffixWithDot)) {
return substr($domain, 0, -strlen($suffixWithDot));
}
return $domain;
}
public static function mapCloudflareZoneStatus(string $status): string
{
if ($status === 'active') {
return 'active';
}
if (in_array($status, ['pending', 'initializing'], true)) {
return 'pending';
}
return 'failed';
}
private static function endsWith(string $haystack, string $needle): bool
{
if ($needle === '') {
return true;
}
return substr($haystack, -strlen($needle)) === $needle;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace app\admin\validate;
use app\admin\model\Domain as DomainModel;
use think\Validate;
/**
* 域名验证器
*/
class Domain extends Validate
{
protected $rule = [
'domain' => 'require|checkDomain',
];
protected $message = [
'domain.require' => '请输入根域名',
];
protected $scene = [
'add' => ['domain'],
];
/**
* @param string $value
* @return bool|string
*/
protected function checkDomain($value)
{
$domain = DomainModel::normalizeDomain((string)$value);
if (!DomainModel::isValidRootDomain($domain)) {
return '请输入合法根域名,不要填写 www 或二级子域名,且不支持中国域名后缀';
}
return true;
}
}
+29
View File
@@ -0,0 +1,29 @@
<form id="add-form" class="form-horizontal" role="form" data-toggle="validator" method="POST" action="">
{:token()}
<div class="alert alert-warning-light">
<p><strong>{:__('Domain input tips title')}</strong></p>
<p>{:__('Domain input tips line1')}</p>
<p>{:__('Domain input tips line2')}</p>
</div>
<div class="alert alert-info-light">
<p><strong>{:__('Domain guide title')}</strong></p>
<p>{:__('Domain guide content')}</p>
</div>
<div class="form-group">
<label for="c-domain" class="control-label col-xs-12 col-sm-2">{:__('Domain')}:</label>
<div class="col-xs-12 col-sm-8">
<input id="c-domain" data-rule="required" class="form-control" name="row[domain]" type="text" value="" placeholder="nihao.com">
</div>
</div>
<div class="form-group layer-footer">
<label class="control-label col-xs-12 col-sm-2"></label>
<div class="col-xs-12 col-sm-8">
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
<button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
</div>
</div>
</form>
+62
View File
@@ -0,0 +1,62 @@
<style>
.table-domain-detail tr td { word-break: break-all; }
.domain-ns-list { margin: 0; padding-left: 18px; }
.domain-ns-list li { margin-bottom: 6px; font-family: Menlo, Monaco, Consolas, monospace; }
</style>
<table class="table table-striped table-domain-detail">
<tbody>
<tr>
<td width="140">{:__('Domain')}</td>
<td>{$row.domain|htmlentities}</td>
</tr>
<tr>
<td>{:__('Full_url')}</td>
<td><a href="{$row.full_url|htmlentities}" target="_blank" rel="noopener noreferrer">{$row.full_url|htmlentities}</a></td>
</tr>
<tr>
<td>{:__('Zone_status')}</td>
<td>{$row.zone_status_text|htmlentities}</td>
</tr>
<tr>
<td>{:__('Ns_status')}</td>
<td>{$row.ns_status_text|htmlentities}</td>
</tr>
<tr>
<td>{:__('Dns_status')}</td>
<td>{$row.dns_status_text|htmlentities}</td>
</tr>
<tr>
<td>{:__('Nameservers')}</td>
<td>
{if !empty($row.nameservers_list)}
<ul class="domain-ns-list">
{volist name="row.nameservers_list" id="ns"}
<li>{$ns|htmlentities}</li>
{/volist}
</ul>
<p class="text-muted">{:__('Domain guide content')}</p>
{else/}
<span class="text-muted">{:__('None')}</span>
{/if}
</td>
</tr>
<tr>
<td>{:__('Check_time')}</td>
<td>{if $row.check_time}{$row.check_time|datetime}{else/}{:__('None')}{/if}</td>
</tr>
<tr>
<td>{:__('Check_result')}</td>
<td>{$row.check_result|default=''|htmlentities}</td>
</tr>
<tr>
<td>{:__('Createtime')}</td>
<td>{$row.createtime|datetime}</td>
</tr>
</tbody>
</table>
<div class="hide layer-footer">
<label class="control-label col-xs-12 col-sm-2"></label>
<div class="col-xs-12 col-sm-8">
<button type="reset" class="btn btn-primary btn-embossed btn-close" onclick="Layer.closeAll();">{:__('Close')}</button>
</div>
</div>
+20
View File
@@ -0,0 +1,20 @@
<div class="panel panel-default panel-intro">
{:build_heading()}
<div class="panel-body">
<div id="myTabContent" class="tab-content">
<div class="tab-pane fade active in" id="one">
<div class="widget-body no-padding">
<div id="toolbar" class="toolbar">
{:build_toolbar('refresh,add')}
</div>
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
data-operate-edit="false"
data-operate-del="false"
width="100%">
</table>
</div>
</div>
</div>
</div>
</div>