Procházet zdrojové kódy

feat: 添加 Zone 本地缓存,支持从 CF 一键同步域名

- 新增 cf_zone 表存储域名信息(状态、NS、套餐、备注等)
- 新增同步接口:从 CF API 拉取全量域名写入本地 DB
- Zone 列表页:改为读本地 DB,支持搜索/分页/备注编辑
- DNS 管理页:域名选择改用本地数据,不再每次调 CF API
- DNS 模板页:应用域名同样改用本地数据

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
urban před 1 měsícem
rodič
revize
eb38a3c22c

+ 1 - 0
magic_admin/base/core/migrate.go

@@ -32,6 +32,7 @@ var allTables = []MigrateTable{
 var bizTables = []MigrateTable{
 	&bizApp.CfAccount{},
 	&bizApp.CfDnsTemplate{},
+	&bizApp.CfZone{},
 }
 
 func Migrates() {

+ 29 - 0
magic_admin/model/biz_modules/app/cf_zone.go

@@ -0,0 +1,29 @@
+package app
+
+// CfZone 本地缓存的 Cloudflare Zone 信息
+type CfZone struct {
+	Id            int64  `json:"id" gorm:"column:id;type:bigint;primarykey;NOT NULL"`
+	CreatedAt     int64  `json:"createdAt" gorm:"column:created_at;type:bigint;comment:创建时间"`
+	UpdatedAt     int64  `json:"updatedAt" gorm:"column:updated_at;type:bigint;comment:更新时间"`
+	AccountId     int64  `json:"accountId" gorm:"column:account_id;type:bigint;index;comment:CF账号ID"`
+	ZoneId        string `json:"zoneId" gorm:"column:zone_id;type:varchar(64);uniqueIndex;comment:CF Zone ID"`
+	Name          string `json:"name" gorm:"column:name;type:varchar(255);index;comment:域名"`
+	Status        string `json:"status" gorm:"column:status;type:varchar(32);comment:状态"`
+	Paused        bool   `json:"paused" gorm:"column:paused;comment:是否暂停"`
+	PlanName      string `json:"planName" gorm:"column:plan_name;type:varchar(64);comment:套餐名称"`
+	NameServers   string `json:"nameServers" gorm:"column:name_servers;type:varchar(500);comment:NS服务器JSON"`
+	ActivatedOn   string `json:"activatedOn" gorm:"column:activated_on;type:varchar(64);comment:激活时间"`
+	Remark        string `json:"remark" gorm:"column:remark;type:varchar(500);comment:备注"`
+}
+
+func (*CfZone) TableName() string {
+	return "cf_zone"
+}
+
+func NewCfZone() *CfZone {
+	return &CfZone{}
+}
+
+func (*CfZone) Comment() string {
+	return "Cloudflare Zone缓存表"
+}

+ 21 - 0
magic_admin/router/app/cf_zone.go

@@ -0,0 +1,21 @@
+package app
+
+import (
+	"github.com/gin-gonic/gin"
+	"go_server/service"
+)
+
+type CfZoneRouter struct{}
+
+func (CfZoneRouter) Route() string {
+	return "/cf_zone"
+}
+
+var cfZoneService = service.RealizationLayer.AppServiceGroup.CfZoneService
+
+func (h CfZoneRouter) Register(group *gin.RouterGroup) {
+	group.GET("sync", cfZoneService.Sync)
+	group.GET("find", cfZoneService.Find)
+	group.GET("all", cfZoneService.All)
+	group.POST("update_remark", cfZoneService.UpdateRemark)
+}

+ 1 - 1
magic_admin/router/app/enter.go

@@ -8,7 +8,7 @@ import (
 // All routes to be registered
 
 var (
-	allRouters = []global.ContextInterface{MagicAssetRouter{}, MagicAssetBillRouter{}, MagicAssetRwCallbackLogRouter{}, MagicAssetRwRecordRouter{}, MagicStakeProductRouter{}, MagicStakeQueueInfoRouter{}, MagicStakeUserCurrentOpsRecordRouter{}, MagicStakeUserCurrentOrderRouter{}, MagicUserRouter{}, MagicUserActionLogRouter{}, MagicUserLogsRouter{}, MagicUserProfitRouter{}, MagicUserProfitRecordRouter{}, MagicUserQuotaRouter{}, NodeBannerRouter{}, NodeInfoRouter{}, NodeOrderRouter{}, NodeOrderPaymentsRouter{}, PromotionUpgradeLevelRouter{}, SysCoinRouter{}, SysI18nRouter{}, SysJobRouter{}, SysLevelConfigRouter{}, SysSignConfigRouter{}, SysStakePeriodJobRouter{}, MagicExtraStakeOrderOpsRecordRouter{}, MagicExtraStakeOrderRouter{}, TgRedPacketConfigRouter{}, TgRedPacketSendRouter{}, TgRedPacketRecordRouter{}, TgGroupRouter{}, TgUserBindRouter{}, CfAccountRouter{}, CfDnsRouter{}, CfDnsTemplateRouter{}}
+	allRouters = []global.ContextInterface{MagicAssetRouter{}, MagicAssetBillRouter{}, MagicAssetRwCallbackLogRouter{}, MagicAssetRwRecordRouter{}, MagicStakeProductRouter{}, MagicStakeQueueInfoRouter{}, MagicStakeUserCurrentOpsRecordRouter{}, MagicStakeUserCurrentOrderRouter{}, MagicUserRouter{}, MagicUserActionLogRouter{}, MagicUserLogsRouter{}, MagicUserProfitRouter{}, MagicUserProfitRecordRouter{}, MagicUserQuotaRouter{}, NodeBannerRouter{}, NodeInfoRouter{}, NodeOrderRouter{}, NodeOrderPaymentsRouter{}, PromotionUpgradeLevelRouter{}, SysCoinRouter{}, SysI18nRouter{}, SysJobRouter{}, SysLevelConfigRouter{}, SysSignConfigRouter{}, SysStakePeriodJobRouter{}, MagicExtraStakeOrderOpsRecordRouter{}, MagicExtraStakeOrderRouter{}, TgRedPacketConfigRouter{}, TgRedPacketSendRouter{}, TgRedPacketRecordRouter{}, TgGroupRouter{}, TgUserBindRouter{}, CfAccountRouter{}, CfDnsRouter{}, CfDnsTemplateRouter{}, CfZoneRouter{}}
 )
 
 type RouterGroup struct {

+ 189 - 0
magic_admin/service/app/cf_zone.go

@@ -0,0 +1,189 @@
+package app
+
+import (
+	"encoding/json"
+	"fmt"
+	"time"
+
+	"github.com/demdxx/gocast"
+	"github.com/gin-gonic/gin"
+	"go_server/model/biz_modules/app"
+	"go_server/model/common/response"
+	"go_server/service/base"
+)
+
+type CfZoneService struct {
+	base.BizCommonService
+}
+
+// Sync 从 CF API 同步域名到本地数据库
+func (s *CfZoneService) Sync(c *gin.Context) {
+	s.SetDbAlias("app")
+	accountId := gocast.ToInt64(c.Query("account_id"))
+	if accountId == 0 {
+		response.Resp(c, "account_id 必填")
+		return
+	}
+
+	// 复用 CfDnsService 的 token 获取逻辑
+	account, ok := base.GetOne[app.CfAccount](s.DB(), "id", accountId)
+	if !ok || account.Status != 1 {
+		response.Resp(c, "账号不存在或已禁用")
+		return
+	}
+
+	dns := &CfDnsService{}
+	dns.SetDbAlias("app")
+
+	// 翻页拉取所有 Zone
+	var allZones []map[string]interface{}
+	page := 1
+	for {
+		url := fmt.Sprintf("%s/zones?per_page=50&page=%d", cfAPIBase, page)
+		data, err := dns.cfRequest("GET", url, account.ApiToken, nil)
+		if err != nil {
+			response.Resp(c, err.Error())
+			return
+		}
+		var result map[string]interface{}
+		json.Unmarshal(data, &result)
+		cfSuccess, _ := result["success"].(bool)
+		if !cfSuccess {
+			response.Resp(c, result)
+			return
+		}
+		raw, _ := result["result"].([]interface{})
+		for _, r := range raw {
+			if z, ok := r.(map[string]interface{}); ok {
+				allZones = append(allZones, z)
+			}
+		}
+		resultInfo, _ := result["result_info"].(map[string]interface{})
+		if int64(page) >= gocast.ToInt64(resultInfo["total_pages"]) {
+			break
+		}
+		page++
+	}
+
+	now := time.Now().Unix()
+	var created, updated int
+
+	for _, z := range allZones {
+		zoneId, _ := z["id"].(string)
+		name, _ := z["name"].(string)
+		status, _ := z["status"].(string)
+		paused, _ := z["paused"].(bool)
+		activatedOn, _ := z["activated_on"].(string)
+
+		planName := ""
+		if plan, ok := z["plan"].(map[string]interface{}); ok {
+			planName, _ = plan["name"].(string)
+		}
+
+		nsBytes, _ := json.Marshal(z["name_servers"])
+
+		existing, exists := base.GetOne[app.CfZone](s.DB(), "zone_id", zoneId)
+		if exists {
+			s.DB().Model(existing).Updates(map[string]interface{}{
+				"name":         name,
+				"status":       status,
+				"paused":       paused,
+				"plan_name":    planName,
+				"name_servers": string(nsBytes),
+				"activated_on": activatedOn,
+				"updated_at":   now,
+			})
+			updated++
+		} else {
+			s.DB().Create(&app.CfZone{
+				AccountId:   accountId,
+				ZoneId:      zoneId,
+				Name:        name,
+				Status:      status,
+				Paused:      paused,
+				PlanName:    planName,
+				NameServers: string(nsBytes),
+				ActivatedOn: activatedOn,
+				CreatedAt:   now,
+				UpdatedAt:   now,
+			})
+			created++
+		}
+	}
+
+	response.Resp(c, map[string]interface{}{
+		"total":   len(allZones),
+		"created": created,
+		"updated": updated,
+	})
+}
+
+// Find 查询本地 Zone 列表
+func (s *CfZoneService) Find(c *gin.Context) {
+	s.SetDbAlias("app")
+	type request[T any] struct {
+		base.ListRequest[T]
+		AccountId int64  `form:"accountId"`
+		Name      string `form:"name"`
+		Status    string `form:"status"`
+	}
+	req := new(request[app.CfZone])
+	if err := c.BindQuery(req); err != nil {
+		response.Resp(c, err.Error())
+		return
+	}
+	db := s.DB()
+	if req.AccountId > 0 {
+		db = db.Where("account_id = ?", req.AccountId)
+	}
+	if req.Name != "" {
+		db = db.Where("name LIKE ?", "%"+req.Name+"%")
+	}
+	if req.Status != "" {
+		db = db.Where("status = ?", req.Status)
+	}
+	db = db.Order("name ASC")
+	resp, err := base.NewQueryBaseHandler(app.NewCfZone()).List(db, req)
+	if err != nil {
+		response.Resp(c, err.Error())
+		return
+	}
+	response.Resp(c, resp)
+}
+
+// UpdateRemark 更新备注
+func (s *CfZoneService) UpdateRemark(c *gin.Context) {
+	s.SetDbAlias("app")
+	type request struct {
+		Id     int64  `json:"id" binding:"required"`
+		Remark string `json:"remark"`
+	}
+	req := new(request)
+	if err := c.BindJSON(req); err != nil {
+		response.Resp(c, err.Error())
+		return
+	}
+	zone, ok := base.GetOne[app.CfZone](s.DB(), "id", req.Id)
+	if !ok {
+		response.Resp(c, "记录不存在")
+		return
+	}
+	s.DB().Model(zone).Updates(map[string]interface{}{
+		"remark":     req.Remark,
+		"updated_at": time.Now().Unix(),
+	})
+	response.Resp(c)
+}
+
+// All 返回某账号下所有 Zone(不分页,供批量操作使用)
+func (s *CfZoneService) All(c *gin.Context) {
+	s.SetDbAlias("app")
+	accountId := gocast.ToInt64(c.Query("account_id"))
+	if accountId == 0 {
+		response.Resp(c, "account_id 必填")
+		return
+	}
+	var zones []app.CfZone
+	s.DB().Where("account_id = ?", accountId).Order("name ASC").Find(&zones)
+	response.Resp(c, zones)
+}

+ 1 - 0
magic_admin/service/app/enter.go

@@ -36,4 +36,5 @@ type ServiceGroup struct {
 	CfAccountService
 	CfDnsService
 	CfDnsTemplateService
+	CfZoneService
 }

+ 7 - 0
magic_admin_web/src/api/modules/cloudflare.js

@@ -23,6 +23,13 @@ export const toggleCfProxy = data => http.post(`${DNS}/record/toggle_proxy`, dat
 export const crossZoneDeleteRecords = data => http.post(`${DNS}/cross_zone/delete`, data);
 export const crossZoneToggleProxy = data => http.post(`${DNS}/cross_zone/toggle_proxy`, data);
 
+// ==================== Zone 本地缓存 ====================
+const ZONE = "/admin/api/app/cf_zone";
+export const syncCfZones = accountId => http.get(`${ZONE}/sync`, { account_id: accountId });
+export const getLocalZoneList = params => http.get(`${ZONE}/find`, params);
+export const getLocalZoneAll = accountId => http.get(`${ZONE}/all`, { account_id: accountId });
+export const updateZoneRemark = data => http.post(`${ZONE}/update_remark`, data);
+
 // ==================== DNS 模板 ====================
 const TEMPLATE = "/admin/api/app/cf_dns_template";
 export const getDnsTemplateList = params => http.get(`${TEMPLATE}/find`, params);

+ 12 - 6
magic_admin_web/src/views/cloudflare/dns/index.vue

@@ -179,7 +179,8 @@ CNAME  www  example.com</pre>
 import { ref, onMounted, defineComponent, h } from 'vue';
 import { ElMessage, ElMessageBox, ElProgress } from 'element-plus';
 import {
-  getCfAccountList, getCfZones, getCfRecords,
+  getCfAccountList, getCfRecords,
+  getLocalZoneAll,
   createCfRecord, batchCreateCfRecord, updateCfRecord,
   deleteCfRecord, toggleCfProxy,
   crossZoneDeleteRecords, crossZoneToggleProxy
@@ -254,14 +255,19 @@ async function onAccountChange() {
   crossProxyZoneIds.value = [];
   if (!selectedAccountId.value) return;
   try {
-    const res = await getCfZones(selectedAccountId.value);
-    const result = res?.data?.result;
+    const res = await getLocalZoneAll(selectedAccountId.value);
+    const result = res?.data;
     if (!Array.isArray(result)) {
-      ElMessage.error('获取域名失败:' + (res?.data?.errors?.[0]?.message || JSON.stringify(res?.data)));
+      ElMessage.error('获取域名失败,请先在「Zone 列表」页同步域名');
       return;
     }
-    zones.value = result;
-    ElMessage.success(`已加载 ${zones.value.length} 个域名`);
+    // 本地 zone 用 zoneId 作为选择值
+    zones.value = result.map(z => ({ ...z, id: z.zoneId }));
+    if (zones.value.length === 0) {
+      ElMessage.warning('本地没有域名数据,请先在「Zone 列表」页点击「从 CF 同步」');
+    } else {
+      ElMessage.success(`已加载 ${zones.value.length} 个域名`);
+    }
   } catch (e) {
     ElMessage.error('获取域名异常:' + e.message);
   }

+ 3 - 3
magic_admin_web/src/views/cloudflare/template/index.vue

@@ -95,7 +95,7 @@ import { ref, onMounted, defineComponent, h } from 'vue';
 import { ElMessage, ElMessageBox, ElProgress } from 'element-plus';
 import {
   getDnsTemplateList, createDnsTemplate, updateDnsTemplate, deleteDnsTemplate,
-  getCfAccountList, getCfZones, batchCreateCfRecord
+  getCfAccountList, getLocalZoneAll, batchCreateCfRecord
 } from '@/api/modules/cloudflare';
 
 const ProgressBlock = defineComponent({
@@ -212,8 +212,8 @@ async function onApplyAccountChange() {
   applyZones.value = [];
   applyZoneIds.value = [];
   if (!applyAccountId.value) return;
-  const res = await getCfZones(applyAccountId.value);
-  applyZones.value = res?.data?.result || [];
+  const res = await getLocalZoneAll(applyAccountId.value);
+  applyZones.value = (res?.data || []).map(z => ({ ...z, id: z.zoneId }));
 }
 
 async function submitApply() {

+ 91 - 41
magic_admin_web/src/views/cloudflare/zones/index.vue

@@ -3,21 +3,21 @@
     <el-card>
       <template #header>
         <div class="card-header">
-          <el-select v-model="selectedAccountId" placeholder="选择 CF 账号" @change="loadZones" style="width:220px">
+          <el-select v-model="selectedAccountId" placeholder="选择 CF 账号" @change="loadZones" style="width:200px">
             <el-option v-for="a in accounts" :key="a.id" :label="a.name" :value="a.id" />
           </el-select>
-          <el-button :disabled="!selectedAccountId" @click="loadZones" :loading="loading">刷新</el-button>
-          <span v-if="zones.length" class="zone-total">共 {{ zones.length }} 个域名</span>
+          <el-input v-model="searchName" placeholder="搜索域名" clearable style="width:200px" @input="loadZones" />
+          <el-button type="primary" :loading="syncing" :disabled="!selectedAccountId" @click="handleSync">
+            从 CF 同步
+          </el-button>
+          <el-button :disabled="!selectedAccountId" @click="loadZones">刷新</el-button>
+          <span v-if="total" class="zone-total">共 {{ total }} 个域名</span>
         </div>
       </template>
 
-      <el-table v-loading="loading" :data="filteredZones" border stripe>
-        <el-table-column prop="name" label="域名" min-width="180" sortable>
-          <template #default="{ row }">
-            <span class="domain-name">{{ row.name }}</span>
-          </template>
-        </el-table-column>
-        <el-table-column label="状态" width="100">
+      <el-table v-loading="loading" :data="zones" border stripe>
+        <el-table-column prop="name" label="域名" min-width="160" sortable />
+        <el-table-column label="状态" width="90">
           <template #default="{ row }">
             <el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
               {{ row.status === 'active' ? '已激活' : row.status }}
@@ -26,49 +26,69 @@
         </el-table-column>
         <el-table-column label="暂停" width="80">
           <template #default="{ row }">
-            <el-tag :type="row.paused ? 'warning' : 'info'" size="small">
-              {{ row.paused ? '暂停' : '正常' }}
-            </el-tag>
+            <el-tag :type="row.paused ? 'warning' : 'info'" size="small">{{ row.paused ? '暂停' : '正常' }}</el-tag>
           </template>
         </el-table-column>
-        <el-table-column label="套餐" width="100">
-          <template #default="{ row }">{{ row.plan?.name || '-' }}</template>
-        </el-table-column>
-        <el-table-column label="NS 服务器" min-width="280">
+        <el-table-column prop="planName" label="套餐" width="100" />
+        <el-table-column label="NS 服务器" min-width="260">
           <template #default="{ row }">
-            <div v-for="ns in row.name_servers" :key="ns" class="ns-item">{{ ns }}</div>
+            <div v-for="ns in parseNs(row.nameServers)" :key="ns" class="ns-item">{{ ns }}</div>
           </template>
         </el-table-column>
-        <el-table-column label="激活时间" width="180">
-          <template #default="{ row }">{{ formatDate(row.activated_on) }}</template>
+        <el-table-column prop="activatedOn" label="激活时间" width="175">
+          <template #default="{ row }">{{ formatDate(row.activatedOn) }}</template>
         </el-table-column>
-        <el-table-column label="操作" width="100" fixed="right">
+        <el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip />
+        <el-table-column label="操作" width="140" fixed="right">
           <template #default="{ row }">
             <el-button link type="primary" @click="goToDns(row)">DNS记录</el-button>
+            <el-button link type="info" @click="handleRemark(row)">备注</el-button>
           </template>
         </el-table-column>
       </el-table>
+
+      <div class="pagination-wrap">
+        <el-pagination
+          v-model:current-page="page"
+          v-model:page-size="pageSize"
+          :total="total"
+          :page-sizes="[20, 50, 100]"
+          layout="total, sizes, prev, pager, next"
+          @change="loadZones"
+        />
+      </div>
     </el-card>
+
+    <el-dialog v-model="remarkDialog" title="编辑备注" width="400px">
+      <el-input v-model="remarkText" type="textarea" :rows="3" placeholder="输入备注" />
+      <template #footer>
+        <el-button @click="remarkDialog = false">取消</el-button>
+        <el-button type="primary" @click="submitRemark">保存</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
 <script setup>
-import { ref, computed, onMounted } from 'vue';
+import { ref, onMounted } from 'vue';
 import { useRouter } from 'vue-router';
 import { ElMessage } from 'element-plus';
-import { getCfAccountList, getCfZones } from '@/api/modules/cloudflare';
+import { getCfAccountList, syncCfZones, getLocalZoneList, updateZoneRemark } from '@/api/modules/cloudflare';
 
 const router = useRouter();
 const accounts = ref([]);
 const zones = ref([]);
 const selectedAccountId = ref(null);
 const loading = ref(false);
+const syncing = ref(false);
 const searchName = ref('');
+const page = ref(1);
+const pageSize = ref(50);
+const total = ref(0);
 
-const filteredZones = computed(() => {
-  if (!searchName.value) return zones.value;
-  return zones.value.filter(z => z.name.includes(searchName.value));
-});
+const remarkDialog = ref(false);
+const remarkText = ref('');
+const remarkRow = ref(null);
 
 onMounted(async () => {
   const res = await getCfAccountList({ pageSize: 100 });
@@ -83,34 +103,64 @@ async function loadZones() {
   if (!selectedAccountId.value) return;
   loading.value = true;
   try {
-    const res = await getCfZones(selectedAccountId.value);
-    const result = res?.data?.result;
-    if (!Array.isArray(result)) {
-      ElMessage.error('获取域名失败');
-      return;
-    }
-    zones.value = result;
-  } catch (e) {
-    ElMessage.error('获取域名异常:' + e.message);
+    const res = await getLocalZoneList({
+      accountId: selectedAccountId.value,
+      name: searchName.value,
+      pageNum: page.value,
+      pageSize: pageSize.value
+    });
+    zones.value = res?.data?.list || [];
+    total.value = Number(res?.data?.total) || 0;
   } finally {
     loading.value = false;
   }
 }
 
+async function handleSync() {
+  syncing.value = true;
+  try {
+    const res = await syncCfZones(selectedAccountId.value);
+    ElMessage.success(`同步完成:新增 ${res?.data?.created},更新 ${res?.data?.updated},共 ${res?.data?.total} 个域名`);
+    page.value = 1;
+    loadZones();
+  } catch (e) {
+    ElMessage.error('同步失败:' + e.message);
+  } finally {
+    syncing.value = false;
+  }
+}
+
+function parseNs(nsJson) {
+  try { return JSON.parse(nsJson) || []; } catch { return []; }
+}
+
 function formatDate(str) {
   if (!str) return '-';
   return new Date(str).toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-');
 }
 
-function goToDns(zone) {
-  router.push({ name: 'cfDns', query: { accountId: selectedAccountId.value, zoneId: zone.id, zoneName: zone.name } });
+function goToDns(row) {
+  router.push({ name: 'cfDns', query: { accountId: selectedAccountId.value, zoneId: row.zoneId, zoneName: row.name } });
+}
+
+function handleRemark(row) {
+  remarkRow.value = row;
+  remarkText.value = row.remark || '';
+  remarkDialog.value = true;
+}
+
+async function submitRemark() {
+  await updateZoneRemark({ id: remarkRow.value.id, remark: remarkText.value });
+  remarkRow.value.remark = remarkText.value;
+  remarkDialog.value = false;
+  ElMessage.success('备注已保存');
 }
 </script>
 
 <style scoped lang="scss">
 .page-container { padding: 20px; }
-.card-header { display: flex; align-items: center; gap: 12px; }
+.card-header { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
 .zone-total { color: #666; font-size: 13px; }
-.domain-name { font-weight: 500; }
-.ns-item { font-size: 12px; color: #555; line-height: 1.6; }
+.ns-item { font-size: 12px; color: #555; line-height: 1.7; }
+.pagination-wrap { margin-top: 16px; display: flex; justify-content: flex-end; }
 </style>