域名管理

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>
+331
View File
@@ -0,0 +1,331 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use fast\Http;
use think\Exception;
/**
* Cloudflare API 服务层
*/
class CloudflareService
{
private const API_BASE = 'https://api.cloudflare.com/client/v4';
private string $apiToken;
private string $accountId;
private string $serverIp;
public function __construct()
{
$config = config('cloudflare');
$this->apiToken = (string)($config['api_token'] ?? '');
$this->accountId = (string)($config['account_id'] ?? '');
$this->serverIp = trim((string)($config['server_ip'] ?? ''));
if ($this->apiToken === '' || $this->accountId === '') {
throw new Exception('Cloudflare API 凭证未配置,请在 .env 中设置 cloudflare.api_token 与 cloudflare.account_id');
}
}
/**
* 创建 Cloudflare Zone
*
* @param string $domain 根域名
* @return array{zone_id: string, name_servers: array, status: string}
* @throws Exception
*/
public function createZone(string $domain): array
{
$body = [
'name' => $domain,
'account' => ['id' => $this->accountId],
'type' => 'full',
];
$response = $this->request('POST', '/zones', $body);
$result = $response['result'] ?? [];
return [
'zone_id' => (string)($result['id'] ?? ''),
'name_servers' => (array)($result['name_servers'] ?? []),
'status' => (string)($result['status'] ?? 'pending'),
];
}
/**
* @throws Exception
*/
public function getZone(string $zoneId): array
{
$response = $this->request('GET', '/zones/' . $zoneId);
return (array)($response['result'] ?? []);
}
/**
* @throws Exception
*/
public function deleteZone(string $zoneId): bool
{
$this->request('DELETE', '/zones/' . $zoneId);
return true;
}
/**
* @throws Exception
*/
public function listDnsRecords(string $zoneId): array
{
$response = $this->request('GET', '/zones/' . $zoneId . '/dns_records', [], ['per_page' => 100]);
return (array)($response['result'] ?? []);
}
/**
* 聚合检测三项状态
* Zone/NS 检测通过后,自动将根域名 A 记录指向 server_ip 并校验
*
* @param array $row 域名记录
* @return array{zone_status: string, ns_status: string, dns_status: string, check_result: string}
*/
public function detectDomain(array $row): array
{
$domain = strtolower(trim((string)($row['domain'] ?? '')));
$zoneId = (string)($row['zone_id'] ?? '');
$expectedNs = $this->decodeNameservers($row['nameservers'] ?? '');
$zoneStatus = 'failed';
$nsStatus = 'pending';
$dnsStatus = 'pending';
$messages = [];
try {
$zone = $this->getZone($zoneId);
$cfStatus = (string)($zone['status'] ?? '');
if ($cfStatus === 'active') {
$zoneStatus = 'active';
$messages[] = 'Zone:已激活';
} elseif (in_array($cfStatus, ['pending', 'initializing'], true)) {
$zoneStatus = 'pending';
$messages[] = 'Zone:待激活';
} else {
$zoneStatus = 'failed';
$messages[] = 'Zone:异常(' . $cfStatus . ')';
}
} catch (\Throwable $e) {
$zoneStatus = 'failed';
$messages[] = 'Zone:检测失败(' . $e->getMessage() . ')';
}
$currentNs = $this->getDomainNameservers($domain);
if ($currentNs === []) {
$nsStatus = 'pending';
$messages[] = 'NS:待验证(未查询到NS记录)';
} elseif ($this->compareNameservers($currentNs, $expectedNs)) {
$nsStatus = 'verified';
$messages[] = 'NS:已验证';
} else {
$nsStatus = 'pending';
$messages[] = 'NS:待验证(当前NS与Cloudflare不一致)';
}
if ($zoneStatus !== 'active') {
$dnsStatus = 'pending';
$messages[] = 'DNS:待创建(Zone未激活)';
} elseif ($nsStatus !== 'verified') {
$dnsStatus = 'pending';
$messages[] = 'DNS:待创建(NS未验证)';
} elseif ($this->serverIp === '') {
$dnsStatus = 'failed';
$messages[] = 'DNS:未配置server_ip';
} elseif (!filter_var($this->serverIp, FILTER_VALIDATE_IP)) {
$dnsStatus = 'failed';
$messages[] = 'DNS:server_ip格式无效';
} else {
try {
if ($this->hasRootCnameConflict($zoneId, $domain)) {
$dnsStatus = 'failed';
$messages[] = 'DNS:根域名存在CNAME记录,无法创建A记录';
} else {
$this->upsertRootARecord($zoneId, $domain, $this->serverIp);
if ($this->verifyRootARecord($zoneId, $domain, $this->serverIp)) {
$dnsStatus = 'created';
$messages[] = 'DNS:已创建(A=' . $this->serverIp . ')';
} else {
$dnsStatus = 'pending';
$messages[] = 'DNS:待创建(A记录未指向' . $this->serverIp . ')';
}
}
} catch (\Throwable $e) {
$dnsStatus = 'failed';
$messages[] = 'DNS:操作失败(' . $e->getMessage() . ')';
}
}
return [
'zone_status' => $zoneStatus,
'ns_status' => $nsStatus,
'dns_status' => $dnsStatus,
'check_result' => implode('; ', $messages),
];
}
/**
* @throws Exception
*/
private function listRootARecords(string $zoneId, string $domain): array
{
$response = $this->request('GET', '/zones/' . $zoneId . '/dns_records', [], [
'type' => 'A',
'name' => $domain,
]);
return (array)($response['result'] ?? []);
}
/**
* @throws Exception
*/
private function hasRootCnameConflict(string $zoneId, string $domain): bool
{
$response = $this->request('GET', '/zones/' . $zoneId . '/dns_records', [], [
'type' => 'CNAME',
'name' => $domain,
]);
return count((array)($response['result'] ?? [])) > 0;
}
/**
* @throws Exception
*/
private function upsertRootARecord(string $zoneId, string $domain, string $ip): void
{
$records = $this->listRootARecords($zoneId, $domain);
if ($records === []) {
$this->request('POST', '/zones/' . $zoneId . '/dns_records', [
'type' => 'A',
'name' => $domain,
'content' => $ip,
'ttl' => 1,
'proxied' => false,
]);
return;
}
foreach ($records as $record) {
$recordId = (string)($record['id'] ?? '');
$content = (string)($record['content'] ?? '');
if ($recordId === '' || $content === $ip) {
continue;
}
$this->request('PATCH', '/zones/' . $zoneId . '/dns_records/' . $recordId, [
'content' => $ip,
'ttl' => 1,
'proxied' => false,
]);
}
}
/**
* @throws Exception
*/
private function verifyRootARecord(string $zoneId, string $domain, string $ip): bool
{
$records = $this->listRootARecords($zoneId, $domain);
foreach ($records as $record) {
if ((string)($record['content'] ?? '') === $ip) {
return true;
}
}
return false;
}
/**
* @throws Exception
*/
private function request(string $method, string $path, array $body = [], array $query = []): array
{
$url = self::API_BASE . $path;
if ($query !== []) {
$url .= '?' . http_build_query($query);
}
$options = [
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->apiToken,
'Content-Type: application/json',
],
];
$payload = $body !== [] ? json_encode($body, JSON_UNESCAPED_UNICODE) : '';
if (strtoupper($method) === 'GET') {
$result = Http::sendRequest($url, [], 'GET', $options);
} elseif (strtoupper($method) === 'DELETE') {
$result = Http::sendRequest($url, $payload, 'DELETE', $options);
} else {
$result = Http::sendRequest($url, $payload, strtoupper($method), $options);
}
if (!$result['ret']) {
throw new Exception('Cloudflare API 请求失败: ' . ($result['msg'] ?? '未知错误'));
}
$decoded = json_decode((string)$result['msg'], true);
if (!is_array($decoded)) {
throw new Exception('Cloudflare API 响应解析失败');
}
if (empty($decoded['success'])) {
$errors = $decoded['errors'] ?? [];
$message = 'Cloudflare API 错误';
if (is_array($errors) && isset($errors[0]['message'])) {
$message = (string)$errors[0]['message'];
}
throw new Exception($message);
}
return $decoded;
}
private function decodeNameservers($nameservers): array
{
if (is_array($nameservers)) {
return $nameservers;
}
if (is_string($nameservers) && $nameservers !== '') {
$decoded = json_decode($nameservers, true);
return is_array($decoded) ? $decoded : [];
}
return [];
}
private function getDomainNameservers(string $domain): array
{
$records = @dns_get_record($domain, DNS_NS);
if (!is_array($records)) {
return [];
}
$list = [];
foreach ($records as $record) {
if (!empty($record['target'])) {
$list[] = $this->normalizeNs((string)$record['target']);
}
}
return array_values(array_unique($list));
}
private function compareNameservers(array $current, array $expected): bool
{
if ($expected === []) {
return false;
}
$currentNorm = array_map([$this, 'normalizeNs'], $current);
$expectedNorm = array_map([$this, 'normalizeNs'], $expected);
sort($currentNorm);
sort($expectedNorm);
return $currentNorm === $expectedNorm;
}
private function normalizeNs(string $ns): string
{
return rtrim(strtolower(trim($ns)), '.');
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use think\Cache;
use think\Exception;
/**
* 域名检测频率限制
* 每分钟最多 2 次,超限后冷却 120 秒
*/
class DomainDetectRateLimitService
{
/** 统计窗口(秒) */
private const WINDOW_SECONDS = 60;
/** 窗口内最大检测次数 */
private const MAX_ATTEMPTS = 2;
/** 超限后冷却时间(秒) */
private const COOLDOWN_SECONDS = 120;
/**
* 校验是否允许检测,不允许时抛出异常
*
* @param int $adminId 管理员 ID
* @param int $domainId 域名记录 ID
* @throws Exception
*/
public function assertCanDetect(int $adminId, int $domainId): void
{
$now = time();
$baseKey = 'domain_detect:' . $adminId . ':' . $domainId;
$cooldownKey = $baseKey . ':cooldown';
$attemptsKey = $baseKey . ':attempts';
$cooldownUntil = (int)Cache::get($cooldownKey, 0);
if ($cooldownUntil > $now) {
$wait = $cooldownUntil - $now;
throw new Exception(sprintf('检测过于频繁,请 %d 秒后再试', $wait));
}
$timestamps = Cache::get($attemptsKey);
if (!is_array($timestamps)) {
$timestamps = [];
}
$timestamps = array_values(array_filter($timestamps, static function ($ts) use ($now): bool {
return is_int($ts) && ($now - $ts) < self::WINDOW_SECONDS;
}));
if (count($timestamps) >= self::MAX_ATTEMPTS) {
Cache::set($cooldownKey, $now + self::COOLDOWN_SECONDS, self::COOLDOWN_SECONDS);
Cache::set($attemptsKey, $timestamps, self::WINDOW_SECONDS + self::COOLDOWN_SECONDS);
throw new Exception(sprintf(
'一分钟最多检测 %d 次,请 %d 秒后再试',
self::MAX_ATTEMPTS,
self::COOLDOWN_SECONDS
));
}
$timestamps[] = $now;
Cache::set($attemptsKey, $timestamps, self::WINDOW_SECONDS + self::COOLDOWN_SECONDS);
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
use think\Env;
/**
* Cloudflare 配置,优先读取 .env
*/
return [
'api_token' => Env::get('cloudflare.api_token', ''),
'account_id' => Env::get('cloudflare.account_id', ''),
'server_ip' => Env::get('cloudflare.server_ip', ''),
];
+7 -7
View File
@@ -1,7 +1,7 @@
<?php <?php
return array ( return array (
'name' => 'LINK S 多功能打粉平台', 'name' => 'LINK-S 多功能打粉',
'beian' => '', 'beian' => '',
'cdnurl' => '', 'cdnurl' => '',
'version' => '1.0.1', 'version' => '1.0.1',
@@ -28,12 +28,6 @@ return array (
'user' => 'User', 'user' => 'User',
'example' => 'Example', 'example' => 'Example',
), ),
'attachmentcategory' =>
array (
'category1' => 'Category1',
'category2' => 'Category2',
'custom' => 'Custom',
),
'mail_type' => '1', 'mail_type' => '1',
'mail_smtp_host' => 'smtp.qq.com', 'mail_smtp_host' => 'smtp.qq.com',
'mail_smtp_port' => '465', 'mail_smtp_port' => '465',
@@ -41,4 +35,10 @@ return array (
'mail_smtp_pass' => '', 'mail_smtp_pass' => '',
'mail_verify_type' => '2', 'mail_verify_type' => '2',
'mail_from' => '', 'mail_from' => '',
'attachmentcategory' =>
array (
'category1' => 'Category1',
'category2' => 'Category2',
'custom' => 'Custom',
),
); );
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# 部署域名检测频率限制(需 root)
set -e
BASE="$(cd "$(dirname "$0")" && pwd)"
PATCH="$BASE/patches"
cp -f "$PATCH/application/common/service/DomainDetectRateLimitService.php" "$BASE/application/common/service/"
cp -f "$PATCH/application/admin/controller/Domain.php" "$BASE/application/admin/controller/"
chown www:www \
"$BASE/application/common/service/DomainDetectRateLimitService.php" \
"$BASE/application/admin/controller/Domain.php"
php -l "$BASE/application/common/service/DomainDetectRateLimitService.php"
php -l "$BASE/application/admin/controller/Domain.php"
echo "检测频率限制部署完成"
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# 部署 server_ip DNS 自动配置逻辑(需 root)
set -e
BASE="$(cd "$(dirname "$0")" && pwd)"
PATCH="$BASE/patches"
cp -f "$PATCH/application/extra/cloudflare.php" "$BASE/application/extra/cloudflare.php"
cp -f "$PATCH/application/common/service/CloudflareService.php" "$BASE/application/common/service/CloudflareService.php"
chown www:www "$BASE/application/extra/cloudflare.php" "$BASE/application/common/service/CloudflareService.php"
php -l "$BASE/application/common/service/CloudflareService.php"
echo "部署完成。请确认 .env 中 cloudflare.server_ip 已填写。"
@@ -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);
}
}
@@ -0,0 +1,331 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use fast\Http;
use think\Exception;
/**
* Cloudflare API 服务层
*/
class CloudflareService
{
private const API_BASE = 'https://api.cloudflare.com/client/v4';
private string $apiToken;
private string $accountId;
private string $serverIp;
public function __construct()
{
$config = config('cloudflare');
$this->apiToken = (string)($config['api_token'] ?? '');
$this->accountId = (string)($config['account_id'] ?? '');
$this->serverIp = trim((string)($config['server_ip'] ?? ''));
if ($this->apiToken === '' || $this->accountId === '') {
throw new Exception('Cloudflare API 凭证未配置,请在 .env 中设置 cloudflare.api_token 与 cloudflare.account_id');
}
}
/**
* 创建 Cloudflare Zone
*
* @param string $domain 根域名
* @return array{zone_id: string, name_servers: array, status: string}
* @throws Exception
*/
public function createZone(string $domain): array
{
$body = [
'name' => $domain,
'account' => ['id' => $this->accountId],
'type' => 'full',
];
$response = $this->request('POST', '/zones', $body);
$result = $response['result'] ?? [];
return [
'zone_id' => (string)($result['id'] ?? ''),
'name_servers' => (array)($result['name_servers'] ?? []),
'status' => (string)($result['status'] ?? 'pending'),
];
}
/**
* @throws Exception
*/
public function getZone(string $zoneId): array
{
$response = $this->request('GET', '/zones/' . $zoneId);
return (array)($response['result'] ?? []);
}
/**
* @throws Exception
*/
public function deleteZone(string $zoneId): bool
{
$this->request('DELETE', '/zones/' . $zoneId);
return true;
}
/**
* @throws Exception
*/
public function listDnsRecords(string $zoneId): array
{
$response = $this->request('GET', '/zones/' . $zoneId . '/dns_records', [], ['per_page' => 100]);
return (array)($response['result'] ?? []);
}
/**
* 聚合检测三项状态
* Zone/NS 检测通过后,自动将根域名 A 记录指向 server_ip 并校验
*
* @param array $row 域名记录
* @return array{zone_status: string, ns_status: string, dns_status: string, check_result: string}
*/
public function detectDomain(array $row): array
{
$domain = strtolower(trim((string)($row['domain'] ?? '')));
$zoneId = (string)($row['zone_id'] ?? '');
$expectedNs = $this->decodeNameservers($row['nameservers'] ?? '');
$zoneStatus = 'failed';
$nsStatus = 'pending';
$dnsStatus = 'pending';
$messages = [];
try {
$zone = $this->getZone($zoneId);
$cfStatus = (string)($zone['status'] ?? '');
if ($cfStatus === 'active') {
$zoneStatus = 'active';
$messages[] = 'Zone:已激活';
} elseif (in_array($cfStatus, ['pending', 'initializing'], true)) {
$zoneStatus = 'pending';
$messages[] = 'Zone:待激活';
} else {
$zoneStatus = 'failed';
$messages[] = 'Zone:异常(' . $cfStatus . ')';
}
} catch (\Throwable $e) {
$zoneStatus = 'failed';
$messages[] = 'Zone:检测失败(' . $e->getMessage() . ')';
}
$currentNs = $this->getDomainNameservers($domain);
if ($currentNs === []) {
$nsStatus = 'pending';
$messages[] = 'NS:待验证(未查询到NS记录)';
} elseif ($this->compareNameservers($currentNs, $expectedNs)) {
$nsStatus = 'verified';
$messages[] = 'NS:已验证';
} else {
$nsStatus = 'pending';
$messages[] = 'NS:待验证(当前NS与Cloudflare不一致)';
}
if ($zoneStatus !== 'active') {
$dnsStatus = 'pending';
$messages[] = 'DNS:待创建(Zone未激活)';
} elseif ($nsStatus !== 'verified') {
$dnsStatus = 'pending';
$messages[] = 'DNS:待创建(NS未验证)';
} elseif ($this->serverIp === '') {
$dnsStatus = 'failed';
$messages[] = 'DNS:未配置server_ip';
} elseif (!filter_var($this->serverIp, FILTER_VALIDATE_IP)) {
$dnsStatus = 'failed';
$messages[] = 'DNS:server_ip格式无效';
} else {
try {
if ($this->hasRootCnameConflict($zoneId, $domain)) {
$dnsStatus = 'failed';
$messages[] = 'DNS:根域名存在CNAME记录,无法创建A记录';
} else {
$this->upsertRootARecord($zoneId, $domain, $this->serverIp);
if ($this->verifyRootARecord($zoneId, $domain, $this->serverIp)) {
$dnsStatus = 'created';
$messages[] = 'DNS:已创建(A=' . $this->serverIp . ')';
} else {
$dnsStatus = 'pending';
$messages[] = 'DNS:待创建(A记录未指向' . $this->serverIp . ')';
}
}
} catch (\Throwable $e) {
$dnsStatus = 'failed';
$messages[] = 'DNS:操作失败(' . $e->getMessage() . ')';
}
}
return [
'zone_status' => $zoneStatus,
'ns_status' => $nsStatus,
'dns_status' => $dnsStatus,
'check_result' => implode('; ', $messages),
];
}
/**
* @throws Exception
*/
private function listRootARecords(string $zoneId, string $domain): array
{
$response = $this->request('GET', '/zones/' . $zoneId . '/dns_records', [], [
'type' => 'A',
'name' => $domain,
]);
return (array)($response['result'] ?? []);
}
/**
* @throws Exception
*/
private function hasRootCnameConflict(string $zoneId, string $domain): bool
{
$response = $this->request('GET', '/zones/' . $zoneId . '/dns_records', [], [
'type' => 'CNAME',
'name' => $domain,
]);
return count((array)($response['result'] ?? [])) > 0;
}
/**
* @throws Exception
*/
private function upsertRootARecord(string $zoneId, string $domain, string $ip): void
{
$records = $this->listRootARecords($zoneId, $domain);
if ($records === []) {
$this->request('POST', '/zones/' . $zoneId . '/dns_records', [
'type' => 'A',
'name' => $domain,
'content' => $ip,
'ttl' => 1,
'proxied' => false,
]);
return;
}
foreach ($records as $record) {
$recordId = (string)($record['id'] ?? '');
$content = (string)($record['content'] ?? '');
if ($recordId === '' || $content === $ip) {
continue;
}
$this->request('PATCH', '/zones/' . $zoneId . '/dns_records/' . $recordId, [
'content' => $ip,
'ttl' => 1,
'proxied' => false,
]);
}
}
/**
* @throws Exception
*/
private function verifyRootARecord(string $zoneId, string $domain, string $ip): bool
{
$records = $this->listRootARecords($zoneId, $domain);
foreach ($records as $record) {
if ((string)($record['content'] ?? '') === $ip) {
return true;
}
}
return false;
}
/**
* @throws Exception
*/
private function request(string $method, string $path, array $body = [], array $query = []): array
{
$url = self::API_BASE . $path;
if ($query !== []) {
$url .= '?' . http_build_query($query);
}
$options = [
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->apiToken,
'Content-Type: application/json',
],
];
$payload = $body !== [] ? json_encode($body, JSON_UNESCAPED_UNICODE) : '';
if (strtoupper($method) === 'GET') {
$result = Http::sendRequest($url, [], 'GET', $options);
} elseif (strtoupper($method) === 'DELETE') {
$result = Http::sendRequest($url, $payload, 'DELETE', $options);
} else {
$result = Http::sendRequest($url, $payload, strtoupper($method), $options);
}
if (!$result['ret']) {
throw new Exception('Cloudflare API 请求失败: ' . ($result['msg'] ?? '未知错误'));
}
$decoded = json_decode((string)$result['msg'], true);
if (!is_array($decoded)) {
throw new Exception('Cloudflare API 响应解析失败');
}
if (empty($decoded['success'])) {
$errors = $decoded['errors'] ?? [];
$message = 'Cloudflare API 错误';
if (is_array($errors) && isset($errors[0]['message'])) {
$message = (string)$errors[0]['message'];
}
throw new Exception($message);
}
return $decoded;
}
private function decodeNameservers($nameservers): array
{
if (is_array($nameservers)) {
return $nameservers;
}
if (is_string($nameservers) && $nameservers !== '') {
$decoded = json_decode($nameservers, true);
return is_array($decoded) ? $decoded : [];
}
return [];
}
private function getDomainNameservers(string $domain): array
{
$records = @dns_get_record($domain, DNS_NS);
if (!is_array($records)) {
return [];
}
$list = [];
foreach ($records as $record) {
if (!empty($record['target'])) {
$list[] = $this->normalizeNs((string)$record['target']);
}
}
return array_values(array_unique($list));
}
private function compareNameservers(array $current, array $expected): bool
{
if ($expected === []) {
return false;
}
$currentNorm = array_map([$this, 'normalizeNs'], $current);
$expectedNorm = array_map([$this, 'normalizeNs'], $expected);
sort($currentNorm);
sort($expectedNorm);
return $currentNorm === $expectedNorm;
}
private function normalizeNs(string $ns): string
{
return rtrim(strtolower(trim($ns)), '.');
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use think\Cache;
use think\Exception;
/**
* 域名检测频率限制
* 每分钟最多 2 次,超限后冷却 120 秒
*/
class DomainDetectRateLimitService
{
/** 统计窗口(秒) */
private const WINDOW_SECONDS = 60;
/** 窗口内最大检测次数 */
private const MAX_ATTEMPTS = 2;
/** 超限后冷却时间(秒) */
private const COOLDOWN_SECONDS = 120;
/**
* 校验是否允许检测,不允许时抛出异常
*
* @param int $adminId 管理员 ID
* @param int $domainId 域名记录 ID
* @throws Exception
*/
public function assertCanDetect(int $adminId, int $domainId): void
{
$now = time();
$baseKey = 'domain_detect:' . $adminId . ':' . $domainId;
$cooldownKey = $baseKey . ':cooldown';
$attemptsKey = $baseKey . ':attempts';
$cooldownUntil = (int)Cache::get($cooldownKey, 0);
if ($cooldownUntil > $now) {
$wait = $cooldownUntil - $now;
throw new Exception(sprintf('检测过于频繁,请 %d 秒后再试', $wait));
}
$timestamps = Cache::get($attemptsKey);
if (!is_array($timestamps)) {
$timestamps = [];
}
$timestamps = array_values(array_filter($timestamps, static function ($ts) use ($now): bool {
return is_int($ts) && ($now - $ts) < self::WINDOW_SECONDS;
}));
if (count($timestamps) >= self::MAX_ATTEMPTS) {
Cache::set($cooldownKey, $now + self::COOLDOWN_SECONDS, self::COOLDOWN_SECONDS);
Cache::set($attemptsKey, $timestamps, self::WINDOW_SECONDS + self::COOLDOWN_SECONDS);
throw new Exception(sprintf(
'一分钟最多检测 %d 次,请 %d 秒后再试',
self::MAX_ATTEMPTS,
self::COOLDOWN_SECONDS
));
}
$timestamps[] = $now;
Cache::set($attemptsKey, $timestamps, self::WINDOW_SECONDS + self::COOLDOWN_SECONDS);
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
use think\Env;
/**
* Cloudflare 配置,优先读取 .env
*/
return [
'api_token' => Env::get('cloudflare.api_token', ''),
'account_id' => Env::get('cloudflare.account_id', ''),
'server_ip' => Env::get('cloudflare.server_ip', ''),
];
+132
View File
@@ -0,0 +1,132 @@
define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) {
var Controller = {
index: function () {
Table.api.init({
extend: {
index_url: 'domain/index' + location.search,
add_url: 'domain/add',
edit_url: '',
del_url: '',
multi_url: '',
table: 'domain',
}
});
var table = $("#table");
table.bootstrapTable({
url: $.fn.bootstrapTable.defaults.extend.index_url,
pk: 'id',
sortName: 'id',
sortOrder: 'desc',
fixedColumns: true,
fixedRightNumber: 1,
columns: [
[
{checkbox: true},
{field: 'domain', title: __('Domain'), operate: 'LIKE'},
{field: 'full_url', title: __('Full_url'), operate: false, formatter: Table.api.formatter.url},
{field: 'zone_status', title: __('Zone_status'), searchList: Config.zoneStatusList, formatter: Table.api.formatter.status},
{field: 'ns_status', title: __('Ns_status'), searchList: Config.nsStatusList, formatter: Table.api.formatter.status},
{field: 'dns_status', title: __('Dns_status'), searchList: Config.dnsStatusList, formatter: Table.api.formatter.status},
{field: 'check_time', title: __('Check_time'), operate: 'RANGE', addclass: 'datetimerange', autocomplete: false, formatter: Table.api.formatter.datetime, sortable: true},
{field: 'check_result', title: __('Check_result'), operate: 'LIKE', class: 'autocontent', formatter: Table.api.formatter.content},
{field: 'createtime', title: __('Createtime'), operate: 'RANGE', addclass: 'datetimerange', autocomplete: false, formatter: Table.api.formatter.datetime, sortable: true},
{
field: 'operate',
title: __('Operate'),
table: table,
events: Table.api.events.operate,
buttons: [
{
name: 'detail',
text: __('Detail'),
icon: 'fa fa-list',
classname: 'btn btn-info btn-xs btn-detail btn-dialog',
url: 'domain/detail'
},
{
name: 'detect',
text: __('Detect'),
icon: 'fa fa-refresh',
classname: 'btn btn-success btn-xs btn-detect',
url: 'domain/detect',
refresh: true
},
{
name: 'delconfirm',
text: __('Del'),
icon: 'fa fa-trash',
classname: 'btn btn-danger btn-xs btn-delconfirm'
}
],
formatter: Table.api.formatter.operate
}
]
]
});
table.on('click', '.btn-detect', function (e) {
e.preventDefault();
e.stopPropagation();
var that = this;
var rowIndex = $(that).data('row-index');
var row = Table.api.getrowbyindex(table, rowIndex);
if (!row) {
return false;
}
$(that).addClass('disabled');
Fast.api.ajax({
url: 'domain/detect/ids/' + row.id,
type: 'post'
}, function () {
table.bootstrapTable('refresh');
return false;
}, function () {
$(that).removeClass('disabled');
});
});
table.on('click', '.btn-delconfirm', function (e) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
var that = this;
var rowIndex = $(that).data('row-index');
var row = Table.api.getrowbyindex(table, rowIndex);
if (!row) {
return false;
}
Layer.prompt({
title: __('Delete domain confirm prompt'),
formType: 0,
value: ''
}, function (value, index) {
if ($.trim(value) !== row.domain) {
Toastr.error(__('Delete domain confirm mismatch'));
return false;
}
Layer.close(index);
Fast.api.ajax({
url: 'domain/del',
data: {ids: row.id, confirm_domain: $.trim(value)}
}, function () {
table.bootstrapTable('refresh');
});
});
});
Table.api.bindevent(table);
},
add: function () {
Controller.api.bindevent();
},
api: {
bindevent: function () {
Form.api.bindevent($("form[role=form]"));
}
}
};
return Controller;
});