Ver código fonte

将短信功能添加至redis

lxylxy123321 1 semana atrás
pai
commit
c940dd9b82

+ 0 - 3
.gitignore

@@ -1,13 +1,10 @@
 # Backend
-backend/.env
 **/__pycache__/
 **/*.pyc
 **/*.pyo
-backend/.venv/
 # Frontend
 frontend/node_modules/
 frontend/dist/
-frontend/.env
 # Logs
 **/*.log
 # claude

+ 29 - 0
backend/.env

@@ -0,0 +1,29 @@
+# Database
+DB_HOST=47.109.151.80
+DB_PORT=5432
+DB_USER=license
+DB_PASSWORD=7HbPaFazwdnNYA4d
+DB_NAME=license
+
+# Server
+HOST=0.0.0.0
+PORT=8000
+DEBUG=true
+
+# 阿里云短信配置
+SMS_ACCESS_KEY_ID=LTAI5tMVcx2okSuuEffkBhQm
+SMS_ACCESS_KEY_SECRET=BPi38C8Ue0fdTEqIISf6UxrxuuOkkO
+SMS_SIGN_NAME=四川网讯创智数字产业发展
+
+# 短信模板 CODE
+SMS_TEMPLATE_CODE_VERIFY=SMS_333915522
+SMS_TEMPLATE_CODE_EXPIRED=SMS_506350367
+SMS_TEMPLATE_CODE_RESTORED=SMS_506275397
+SMS_TEMPLATE_CODE_WARNING=SMS_506330424
+
+# Redis
+REDIS_HOST=192.168.0.3
+REDIS_PORT=6379
+REDIS_PASSWORD=Wxcz666@
+REDIS_DB=0
+

+ 6 - 0
backend/app/config.py

@@ -31,6 +31,12 @@ class Settings(BaseSettings):
     sms_template_code_restored: str = "SMS_506275397"
     sms_template_code_warning: str = "SMS_506330424"
 
+    # Redis
+    redis_host: str = "localhost"
+    redis_port: int = 6379
+    redis_password: str = ""
+    redis_db: int = 0
+
     @property
     def database_url(self) -> str:
         """拼接 SQLAlchemy 异步连接字符串"""

+ 56 - 19
backend/app/main.py

@@ -1,7 +1,7 @@
 import asyncio
 import logging
 from contextlib import asynccontextmanager
-from datetime import datetime, timezone
+from datetime import datetime, timezone, timedelta
 
 from fastapi import FastAPI
 from fastapi.middleware.cors import CORSMiddleware
@@ -11,12 +11,18 @@ from app.routers import domains, monitoring, license as license_router
 from app.routers.license import public_router as public_license_router
 from app.database import async_session
 from app.services.license import _notify_on_expired, _notify_on_warning
-from sqlalchemy import select, text
+from app.services.domain_fetch import fetch_domain_transactions
 from app.models.license import SuperAdminLicense
+from app.models.domain import MonitoredDomain
+from app.redis import get_redis, close_redis
+from sqlalchemy import select, text
 
 logger = logging.getLogger(__name__)
 
 
+CST = timezone(timedelta(hours=8))
+
+
 async def _check_licenses():
     """定期检查 License:过期状态更新 + 7 天预警"""
     while True:
@@ -35,12 +41,10 @@ async def _check_licenses():
                     await session.commit()
                     await _notify_on_expired(session, lic)
 
-                # 2. 检查剩余 7 天预警(未发送过的)
-                from datetime import timedelta
+                # 2. 检查剩余 7 天预警(_notify_on_warning 内部通过 Redis 去重)
                 warning_result = await session.execute(
                     select(SuperAdminLicense).where(
                         SuperAdminLicense.status == "active",
-                        SuperAdminLicense.warning_sent == False,
                         SuperAdminLicense.expires_at > text("NOW()"),
                         SuperAdminLicense.expires_at <= text("NOW() + INTERVAL '7 days'"),
                     )
@@ -49,15 +53,47 @@ async def _check_licenses():
                     days_left = (lic.expires_at - datetime.now(timezone.utc)).days
                     logger.info("检测到 License #%d 剩余 %d 天,发送预警短信", lic.id, days_left)
                     await _notify_on_warning(session, lic, days_left)
-                    lic.warning_sent = True
-                    await session.commit()
         except Exception:
             logger.exception("定时检查 License 异常")
 
-        # 每 24 小时检查一次
         await asyncio.sleep(24 * 3600)
 
 
+async def _daily_fetch():
+    """定时爬取:每分钟检查 Redis 配置,到了目标时间就爬取当天流水"""
+    while True:
+        try:
+            r = await get_redis()
+            enabled = await r.get("fetch_schedule:enabled")
+            schedule_time = await r.get("fetch_schedule:time")
+
+            if enabled == "true" and schedule_time:
+                h, m = map(int, schedule_time.split(":"))
+                now = datetime.now(CST)
+                today = now.strftime("%Y-%m-%d")
+                last_fetch = await r.get("fetch:last_date")
+
+                # 已过目标时间且今天还没爬过
+                if now.hour * 60 + now.minute >= h * 60 + m and last_fetch != today:
+                    logger.info("开始定时爬取当天流水: %s", today)
+                    async with async_session() as session:
+                        domain_result = await session.execute(
+                            select(MonitoredDomain).where(MonitoredDomain.is_active == True)
+                        )
+                        for d in domain_result.scalars().all():
+                            try:
+                                await fetch_domain_transactions(d.domain, session, fetch_date=today)
+                                logger.info("域名 %s 当天流水爬取完成", d.domain)
+                            except Exception:
+                                logger.exception("域名 %s 当天流水爬取失败", d.domain)
+                    await r.set("fetch:last_date", today)
+                    logger.info("当天定时爬取全部完成")
+        except Exception:
+            logger.exception("定时爬取异常")
+
+        await asyncio.sleep(60)
+
+
 @asynccontextmanager
 async def lifespan(app: FastAPI):
     # 启动时立即检查一次
@@ -76,12 +112,11 @@ async def lifespan(app: FastAPI):
                 await session.commit()
                 await _notify_on_expired(session, lic)
 
-            # 2. 检查剩余 7 天预警(未发送过的
+            # 2. 检查剩余 7 天预警(_notify_on_warning 内部通过 Redis 去重
             from datetime import timedelta
             warning_result = await session.execute(
                 select(SuperAdminLicense).where(
                     SuperAdminLicense.status == "active",
-                    SuperAdminLicense.warning_sent == False,
                     SuperAdminLicense.expires_at > text("NOW()"),
                     SuperAdminLicense.expires_at <= text("NOW() + INTERVAL '7 days'"),
                 )
@@ -90,22 +125,24 @@ async def lifespan(app: FastAPI):
                 days_left = (lic.expires_at - datetime.now(timezone.utc)).days
                 logger.info("启动时检测到 License #%d 剩余 %d 天,发送预警短信", lic.id, days_left)
                 await _notify_on_warning(session, lic, days_left)
-                lic.warning_sent = True
-                await session.commit()
 
             logger.info("启动检查完成")
     except Exception:
         logger.exception("启动时检查 License 异常")
 
     # 启动后台定时任务
-    task = asyncio.create_task(_check_licenses())
-    logger.info("后台任务已启动:每 24 小时检查一次 License")
+    license_task = asyncio.create_task(_check_licenses())
+    fetch_task = asyncio.create_task(_daily_fetch())
+    logger.info("后台任务已启动:License 检查 + 定时爬取")
     yield
-    task.cancel()
-    try:
-        await task
-    except asyncio.CancelledError:
-        pass
+    license_task.cancel()
+    fetch_task.cancel()
+    for task in (license_task, fetch_task):
+        try:
+            await task
+        except asyncio.CancelledError:
+            pass
+    await close_redis()
 
 
 app = FastAPI(

+ 1 - 2
backend/app/models/license.py

@@ -1,4 +1,4 @@
-from sqlalchemy import Column, Integer, String, Text, DateTime, Numeric, Boolean, func
+from sqlalchemy import Column, Integer, String, Text, DateTime, Numeric, func
 from app.models import Base
 
 
@@ -15,6 +15,5 @@ class SuperAdminLicense(Base):
     max_tenants = Column(Integer)
     max_users_per_tenant = Column(Integer)
     remark = Column(Text)
-    warning_sent = Column(Boolean, server_default="false", default=False)
     created_at = Column(DateTime(timezone=True), server_default=func.now())
     updated_at = Column(DateTime(timezone=True), onupdate=func.now())

+ 28 - 0
backend/app/redis.py

@@ -0,0 +1,28 @@
+import logging
+from redis.asyncio import Redis
+
+from app.config import settings
+
+logger = logging.getLogger(__name__)
+
+redis_client: Redis | None = None
+
+
+async def get_redis() -> Redis:
+    global redis_client
+    if redis_client is None:
+        redis_client = Redis(
+            host=settings.redis_host,
+            port=settings.redis_port,
+            password=settings.redis_password or None,
+            db=settings.redis_db,
+            decode_responses=True,
+        )
+    return redis_client
+
+
+async def close_redis() -> None:
+    global redis_client
+    if redis_client:
+        await redis_client.aclose()
+        redis_client = None

+ 34 - 2
backend/app/routers/domains.py

@@ -10,6 +10,7 @@ from app.schemas.domain import (
     MonitoredDomainResponse,
 )
 from app.services.domain_fetch import fetch_domain_transactions
+from app.redis import get_redis
 
 router = APIRouter(prefix="/api/domains", tags=["domains"])
 
@@ -120,10 +121,14 @@ async def get_domain_transactions(
 
 @router.post("/fetch-all", status_code=202)
 async def fetch_all_transactions(
-    fetch_date: str | None = Query(None, description="爬取指定日期,格式 YYYY-MM-DD,不传则查全部"),
+    fetch_date: str | None = Query(None, description="爬取指定日期,格式 YYYY-MM-DD,不传则爬取当天"),
     db: AsyncSession = Depends(get_db),
 ):
-    """批量爬取所有已启用域名的监控数据"""
+    """批量爬取所有已启用域名的监控数据,默认只爬取当天"""
+    if not fetch_date:
+        from datetime import datetime, timezone, timedelta
+        CST = timezone(timedelta(hours=8))
+        fetch_date = datetime.now(CST).strftime("%Y-%m-%d")
     result = await db.execute(
         select(MonitoredDomain).where(MonitoredDomain.is_active == True)
     )
@@ -135,3 +140,30 @@ async def fetch_all_transactions(
         except Exception as e:
             errors.append({"domain": d.domain, "error": str(e)})
     return {"status": "ok", "total": len(domains), "errors": errors}
+
+
+class ScheduleConfigUpdate(BaseModel):
+    """更新定时爬取配置"""
+    enabled: bool
+    schedule_time: str  # HH:MM
+
+
+@router.get("/schedule")
+async def get_schedule_config():
+    """获取定时爬取配置(Redis)"""
+    r = await get_redis()
+    enabled = await r.get("fetch_schedule:enabled")
+    schedule_time = await r.get("fetch_schedule:time")
+    return {
+        "enabled": enabled == "true",
+        "schedule_time": schedule_time or "02:00",
+    }
+
+
+@router.put("/schedule")
+async def update_schedule_config(payload: ScheduleConfigUpdate):
+    """更新定时爬取配置(Redis)"""
+    r = await get_redis()
+    await r.set("fetch_schedule:enabled", "true" if payload.enabled else "false")
+    await r.set("fetch_schedule:time", payload.schedule_time)
+    return {"message": "配置已保存", "enabled": payload.enabled, "schedule_time": payload.schedule_time}

+ 24 - 3
backend/app/services/license.py

@@ -19,6 +19,7 @@ from app.schemas.license import (
     LicenseListResponse,
 )
 from app.services.sms import send_license_expired, send_license_restored, send_license_warning
+from app.redis import get_redis
 import logging
 
 logger = logging.getLogger(__name__)
@@ -63,6 +64,10 @@ async def _get_contact_for_license(db: AsyncSession, lic: SuperAdminLicense) ->
 async def _notify_on_expired(db: AsyncSession, lic: SuperAdminLicense) -> str:
     """License valid 从 true 变为 false 时发送过期短信。返回: sent | skipped | failed"""
     try:
+        r = await get_redis()
+        if await r.get(f"sms:expired_sent:{lic.id}"):
+            logger.info("License #%d 已发送过过期短信,跳过", lic.id)
+            return "skipped"
         phone, company = await _get_contact_for_license(db, lic)
         if not phone:
             logger.warning("License #%d 无联系人手机号,跳过预警短信", lic.id)
@@ -71,6 +76,7 @@ async def _notify_on_expired(db: AsyncSession, lic: SuperAdminLicense) -> str:
         ok, reason = await send_license_expired(phone, company)
         if ok:
             logger.info("License 预警短信发送成功: license_id=%d", lic.id)
+            await r.setex(f"sms:expired_sent:{lic.id}", 86400 * 30, "1")  # 30 天过期
             return "sent"
         logger.error("License 预警短信发送失败: license_id=%d, 原因=%s", lic.id, reason)
         return "failed"
@@ -90,6 +96,9 @@ async def _notify_on_restored(db: AsyncSession, lic: SuperAdminLicense) -> str:
         ok, reason = await send_license_restored(phone, company)
         if ok:
             logger.info("License 恢复短信发送成功: license_id=%d", lic.id)
+            r = await get_redis()
+            await r.delete(f"sms:expired_sent:{lic.id}")
+            await r.delete(f"sms:warning_sent:{lic.id}")
             return "sent"
         logger.error("License 恢复短信发送失败: license_id=%d, 原因=%s", lic.id, reason)
         return "failed"
@@ -101,6 +110,10 @@ async def _notify_on_restored(db: AsyncSession, lic: SuperAdminLicense) -> str:
 async def _notify_on_warning(db: AsyncSession, lic: SuperAdminLicense, days_left: int) -> str:
     """License 即将过期(剩余 7 天)预警短信。返回: sent | skipped | failed"""
     try:
+        r = await get_redis()
+        if await r.get(f"sms:warning_sent:{lic.id}"):
+            logger.info("License #%d 已发送过预警短信,跳过", lic.id)
+            return "skipped"
         phone, company = await _get_contact_for_license(db, lic)
         if not phone:
             logger.warning("License #%d 无联系人手机号,跳过即将过期预警短信", lic.id)
@@ -109,6 +122,7 @@ async def _notify_on_warning(db: AsyncSession, lic: SuperAdminLicense, days_left
         ok, reason = await send_license_warning(phone, company, days_left)
         if ok:
             logger.info("License 即将过期预警短信发送成功: license_id=%d", lic.id)
+            await r.setex(f"sms:warning_sent:{lic.id}", 86400 * 30, "1")
             return "sent"
         logger.error("License 即将过期预警短信发送失败: license_id=%d, 原因=%s", lic.id, reason)
         return "failed"
@@ -326,9 +340,13 @@ async def update_license(
         raise ValueError("没有需要更新的字段")
     # 修改过期时间后已超出 7 天窗口,重置预警标记
     if payload.expires_at is not None and _calc_days_left(lic.expires_at) > 7:
-        lic.warning_sent = False
+        r = await get_redis()
+        await r.delete(f"sms:warning_sent:{lic.id}")
+        await r.delete(f"sms:expired_sent:{lic.id}")
     elif old_status != "active" and lic.status == "active":
-        lic.warning_sent = False
+        r = await get_redis()
+        await r.delete(f"sms:warning_sent:{lic.id}")
+        await r.delete(f"sms:expired_sent:{lic.id}")
     await db.commit()
 
     # valid 变化时发送短信
@@ -352,8 +370,11 @@ async def restore_license(
     if lic.status != "revoked":
         raise ValueError("仅可恢复已吊销的 License")
     lic.status = "active"
-    lic.warning_sent = False  # 恢复后重置预警标记
     await db.commit()
+    # 清除 Redis 去重标记
+    r = await get_redis()
+    await r.delete(f"sms:warning_sent:{lic.id}")
+    await r.delete(f"sms:expired_sent:{lic.id}")
     sms_status = await _notify_on_restored(db, lic)
     return {"message": "License已恢复", "sms_status": sms_status}
 

+ 1 - 0
backend/pyproject.toml

@@ -9,6 +9,7 @@ dependencies = [
     "httpx>=0.28.1",
     "pydantic-settings>=2.14.1",
     "python-dotenv>=1.2.2",
+    "redis>=5.2.0",
     "sqlalchemy[asyncio]>=2.0.49",
     "uvicorn[standard]>=0.46.0",
 ]

+ 23 - 0
backend/uv.lock

@@ -33,6 +33,15 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
 ]
 
+[[package]]
+name = "async-timeout"
+version = "5.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" },
+]
+
 [[package]]
 name = "asyncpg"
 version = "0.31.0"
@@ -91,6 +100,7 @@ dependencies = [
     { name = "httpx" },
     { name = "pydantic-settings" },
     { name = "python-dotenv" },
+    { name = "redis" },
     { name = "sqlalchemy", extra = ["asyncio"] },
     { name = "uvicorn", extra = ["standard"] },
 ]
@@ -102,6 +112,7 @@ requires-dist = [
     { name = "httpx", specifier = ">=0.28.1" },
     { name = "pydantic-settings", specifier = ">=2.14.1" },
     { name = "python-dotenv", specifier = ">=1.2.2" },
+    { name = "redis", specifier = ">=5.2.0" },
     { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.49" },
     { name = "uvicorn", extras = ["standard"], specifier = ">=0.46.0" },
 ]
@@ -486,6 +497,18 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
 ]
 
+[[package]]
+name = "redis"
+version = "7.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "async-timeout", marker = "python_full_version < '3.11.3'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" },
+]
+
 [[package]]
 name = "sqlalchemy"
 version = "2.0.49"

+ 1 - 0
frontend/.env

@@ -0,0 +1 @@
+VITE_API_BASE_URL=http://localhost:8000

+ 21 - 22
frontend/src/App.tsx

@@ -1,4 +1,4 @@
-import { useState, useEffect, useRef } from "react";
+import { useState, useEffect } from "react";
 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
 import { domainApi } from "./api/domains";
 import { monitoringApi } from "./api/monitoring";
@@ -115,13 +115,29 @@ function FetchControls() {
   const [fetchDate, setFetchDate] = useState("");
   const [fetchingByDate, setFetchingByDate] = useState(false);
   const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
-  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const [configLoaded, setConfigLoaded] = useState(false);
+
+  // 加载配置
+  useEffect(() => {
+    domainApi.getSchedule().then(res => {
+      setAutoFetch(res.data.enabled);
+      setScheduleTime(res.data.schedule_time);
+      setConfigLoaded(true);
+    }).catch(() => setConfigLoaded(true));
+  }, []);
 
   const showToast = (message: string, type: "success" | "error") => {
     setToast({ message, type });
     setTimeout(() => setToast(null), 4000);
   };
 
+  // 保存配置
+  const handleSaveSchedule = () => {
+    domainApi.saveSchedule({ enabled: autoFetch, schedule_time: scheduleTime }).then(res => {
+      showToast("配置已保存", "success");
+    }).catch(() => showToast("保存失败", "error"));
+  };
+
   const batchMutation = useMutation({
     mutationFn: () => domainApi.fetchAll(),
     onSuccess: (res) => {
@@ -155,26 +171,6 @@ function FetchControls() {
     onError: () => { setFetchingByDate(false); showToast("爬取请求失败,请稍后重试", "error"); },
   });
 
-  // 定时爬取:计算到下一个目标时间点的延迟
-  useEffect(() => {
-    if (!autoFetch) {
-      if (timerRef.current) clearTimeout(timerRef.current);
-      return;
-    }
-    const [h, m] = scheduleTime.split(":").map(Number);
-    const now = new Date();
-    let next = new Date(now);
-    next.setHours(h, m, 0, 0);
-    if (next <= now) next.setDate(next.getDate() + 1);
-    const delay = next.getTime() - now.getTime();
-    const fn = () => {
-      batchMutation.mutate();
-      timerRef.current = setTimeout(fn, 24 * 3600 * 1000);
-    };
-    timerRef.current = setTimeout(fn, delay);
-    return () => { if (timerRef.current) clearTimeout(timerRef.current); };
-  }, [autoFetch, scheduleTime]);
-
   const handleFetchByDate = () => {
     if (!fetchDate) return;
     setFetchingByDate(true);
@@ -192,6 +188,9 @@ function FetchControls() {
             <input type="time" value={scheduleTime} onChange={(e) => setScheduleTime(e.target.value)} style={{ padding: "4px 8px", borderRadius: 6, border: `1px solid ${T.border}`, fontSize: 13, outline: "none" }} />
           </div>
         )}
+        <button onClick={handleSaveSchedule} style={{ padding: "6px 14px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: `1px solid ${T.primary}`, background: "transparent", color: T.primary, fontWeight: 500, whiteSpace: "nowrap" }}>
+          保存配置
+        </button>
         <button onClick={() => batchMutation.mutate()} disabled={batchMutation.isPending} style={{ padding: "6px 14px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: "none", background: batchMutation.isPending ? "#f1f5f9" : T.primary, color: batchMutation.isPending ? T.textSec : "#fff", fontWeight: 500, whiteSpace: "nowrap" }}>
           {batchMutation.isPending ? "爬取中..." : "全部爬取"}
         </button>

+ 6 - 0
frontend/src/api/domains.ts

@@ -20,4 +20,10 @@ export const domainApi = {
 
   /** 批量爬取所有已启用域名的数据 */
   fetchAll: (date?: string) => api.post(`/api/domains/fetch-all${date ? `?fetch_date=${date}` : ""}`),
+
+  /** 获取定时爬取配置 */
+  getSchedule: () => api.get<{ enabled: boolean; schedule_time: string }>("/api/domains/schedule"),
+
+  /** 保存定时爬取配置 */
+  saveSchedule: (data: { enabled: boolean; schedule_time: string }) => api.put("/api/domains/schedule", data),
 };