Jelajahi Sumber

新增license7天内预警

lxylxy123321 1 Minggu lalu
induk
melakukan
93ec681dee

+ 1 - 0
backend/app/config.py

@@ -29,6 +29,7 @@ class Settings(BaseSettings):
     sms_template_code_verify: str = "SMS_333915522"
     sms_template_code_expired: str = "SMS_506350367"
     sms_template_code_restored: str = "SMS_506275397"
+    sms_template_code_warning: str = "SMS_506330424"
 
     @property
     def database_url(self) -> str:

+ 2 - 1
backend/app/database.py

@@ -16,6 +16,7 @@ async def get_db():
 
 async def create_tables():
     """开发环境:通过 SQLAlchemy 直接建表(生产环境请使用 SQL 迁移文件)"""
-    from app.models.domain import Base
+    from app.models import Base
+    from app.models import domain, visitor, monitoring, license  # noqa: F401
     async with engine.begin() as conn:
         await conn.run_sync(Base.metadata.create_all)

+ 105 - 0
backend/app/main.py

@@ -1,13 +1,118 @@
+import asyncio
+import logging
+from contextlib import asynccontextmanager
+from datetime import datetime, timezone
+
 from fastapi import FastAPI
 from fastapi.middleware.cors import CORSMiddleware
+
 from app.config import settings
 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.models.license import SuperAdminLicense
+
+logger = logging.getLogger(__name__)
+
+
+async def _check_licenses():
+    """定期检查 License:过期状态更新 + 7 天预警"""
+    while True:
+        try:
+            async with async_session() as session:
+                # 1. 检查已过期
+                expired_result = await session.execute(
+                    select(SuperAdminLicense).where(
+                        SuperAdminLicense.status == "active",
+                        SuperAdminLicense.expires_at <= text("NOW()"),
+                    )
+                )
+                for lic in expired_result.scalars().all():
+                    logger.info("检测到 License #%d 已过期,更新状态并发送预警短信", lic.id)
+                    lic.status = "expired"
+                    await session.commit()
+                    await _notify_on_expired(session, lic)
+
+                # 2. 检查剩余 7 天预警(未发送过的)
+                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'"),
+                    )
+                )
+                for lic in warning_result.scalars().all():
+                    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)
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    # 启动时立即检查一次
+    try:
+        async with async_session() as session:
+            # 1. 检查已过期
+            expired_result = await session.execute(
+                select(SuperAdminLicense).where(
+                    SuperAdminLicense.status == "active",
+                    SuperAdminLicense.expires_at <= text("NOW()"),
+                )
+            )
+            for lic in expired_result.scalars().all():
+                logger.info("启动时检测到 License #%d 已过期,更新状态并发送预警短信", lic.id)
+                lic.status = "expired"
+                await session.commit()
+                await _notify_on_expired(session, lic)
+
+            # 2. 检查剩余 7 天预警(未发送过的)
+            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'"),
+                )
+            )
+            for lic in warning_result.scalars().all():
+                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")
+    yield
+    task.cancel()
+    try:
+        await task
+    except asyncio.CancelledError:
+        pass
+
 
 app = FastAPI(
     title="域名流水监控",
     version="0.1.0",
     debug=settings.debug,
+    lifespan=lifespan,
 )
 
 # CORS 配置,允许前端 Vite 开发服务器访问

+ 3 - 0
backend/app/models/__init__.py

@@ -0,0 +1,3 @@
+from sqlalchemy.orm import declarative_base
+
+Base = declarative_base()

+ 1 - 3
backend/app/models/domain.py

@@ -1,7 +1,5 @@
 from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, func
-from sqlalchemy.orm import declarative_base
-
-Base = declarative_base()
+from app.models import Base
 
 
 class MonitoredDomain(Base):

+ 3 - 4
backend/app/models/license.py

@@ -1,7 +1,5 @@
-from sqlalchemy import Column, Integer, String, Text, DateTime, Numeric, func
-from sqlalchemy.orm import declarative_base
-
-Base = declarative_base()
+from sqlalchemy import Column, Integer, String, Text, DateTime, Numeric, Boolean, func
+from app.models import Base
 
 
 class SuperAdminLicense(Base):
@@ -17,5 +15,6 @@ 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())

+ 1 - 3
backend/app/models/monitoring.py

@@ -1,7 +1,5 @@
 from sqlalchemy import Column, Integer, String, Boolean, DateTime, Numeric, func
-from sqlalchemy.orm import declarative_base
-
-Base = declarative_base()
+from app.models import Base
 
 
 class SuperAdmin(Base):

+ 1 - 3
backend/app/models/visitor.py

@@ -1,7 +1,5 @@
 from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, func
-from sqlalchemy.orm import declarative_base
-
-Base = declarative_base()
+from app.models import Base
 
 
 class VisitorInfo(Base):

+ 1 - 0
backend/app/schemas/license.py

@@ -42,6 +42,7 @@ class LicenseStatusResponse(BaseModel):
     max_tenants: Optional[int] = None
     max_users_per_tenant: Optional[int] = None
     remark: Optional[str] = None
+    sms_status: Optional[str] = None
 
 
 class SuperAdminOption(BaseModel):

+ 82 - 27
backend/app/services/license.py

@@ -1,7 +1,9 @@
 import math
-from datetime import datetime, timezone
+from datetime import datetime, timezone, timedelta
 from urllib.parse import urlparse
 
+CST = timezone(timedelta(hours=8))  # 东八区
+
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from app.models.license import SuperAdminLicense
@@ -16,7 +18,7 @@ from app.schemas.license import (
     SuperAdminOption,
     LicenseListResponse,
 )
-from app.services.sms import send_license_expired, send_license_restored
+from app.services.sms import send_license_expired, send_license_restored, send_license_warning
 import logging
 
 logger = logging.getLogger(__name__)
@@ -26,7 +28,7 @@ def _to_str(val) -> str:
     if val is None:
         return ""
     if isinstance(val, datetime):
-        return val.isoformat()
+        return val.astimezone(CST).isoformat()
     return str(val)
 
 
@@ -58,24 +60,61 @@ async def _get_contact_for_license(db: AsyncSession, lic: SuperAdminLicense) ->
     return visitor.phone, company
 
 
-async def _notify_on_expired(db: AsyncSession, lic: SuperAdminLicense):
-    """License 过期时发送短信通知(静默失败,不阻断主流程)"""
+async def _notify_on_expired(db: AsyncSession, lic: SuperAdminLicense) -> str:
+    """License valid 从 true 变为 false 时发送过期短信。返回: sent | skipped | failed"""
     try:
         phone, company = await _get_contact_for_license(db, lic)
-        if phone:
-            await send_license_expired(phone, company)
+        if not phone:
+            logger.warning("License #%d 无联系人手机号,跳过预警短信", lic.id)
+            return "skipped"
+        logger.info("发送 License 预警短信(过期): license_id=%d, phone=%s, company=%s", lic.id, phone, company)
+        ok, reason = await send_license_expired(phone, company)
+        if ok:
+            logger.info("License 预警短信发送成功: license_id=%d", lic.id)
+            return "sent"
+        logger.error("License 预警短信发送失败: license_id=%d, 原因=%s", lic.id, reason)
+        return "failed"
     except Exception:
-        logger.exception("发送过期短信失败,license_id=%d", lic.id)
+        logger.exception("发送预警短信异常,license_id=%d", lic.id)
+        return "failed"
 
 
-async def _notify_on_restored(db: AsyncSession, lic: SuperAdminLicense):
-    """License 恢复时发送短信通知(静默失败,不阻断主流程)"""
+async def _notify_on_restored(db: AsyncSession, lic: SuperAdminLicense) -> str:
+    """License valid 从 false 变为 true 时发送恢复短信。返回: sent | skipped | failed"""
     try:
         phone, company = await _get_contact_for_license(db, lic)
-        if phone:
-            await send_license_restored(phone, company)
+        if not phone:
+            logger.warning("License #%d 无联系人手机号,跳过恢复短信", lic.id)
+            return "skipped"
+        logger.info("发送 License 恢复短信: license_id=%d, phone=%s, company=%s", lic.id, phone, company)
+        ok, reason = await send_license_restored(phone, company)
+        if ok:
+            logger.info("License 恢复短信发送成功: license_id=%d", lic.id)
+            return "sent"
+        logger.error("License 恢复短信发送失败: license_id=%d, 原因=%s", lic.id, reason)
+        return "failed"
     except Exception:
-        logger.exception("发送恢复短信失败,license_id=%d", lic.id)
+        logger.exception("发送恢复短信异常,license_id=%d", lic.id)
+        return "failed"
+
+
+async def _notify_on_warning(db: AsyncSession, lic: SuperAdminLicense, days_left: int) -> str:
+    """License 即将过期(剩余 7 天)预警短信。返回: sent | skipped | failed"""
+    try:
+        phone, company = await _get_contact_for_license(db, lic)
+        if not phone:
+            logger.warning("License #%d 无联系人手机号,跳过即将过期预警短信", lic.id)
+            return "skipped"
+        logger.info("发送 License 即将过期预警: license_id=%d, phone=%s, company=%s, days_left=%d", lic.id, phone, company, days_left)
+        ok, reason = await send_license_warning(phone, company, days_left)
+        if ok:
+            logger.info("License 即将过期预警短信发送成功: license_id=%d", lic.id)
+            return "sent"
+        logger.error("License 即将过期预警短信发送失败: license_id=%d, 原因=%s", lic.id, reason)
+        return "failed"
+    except Exception:
+        logger.exception("发送即将过期预警短信异常,license_id=%d", lic.id)
+        return "failed"
 
 
 def _calc_days_left(expires_at: datetime) -> int:
@@ -104,6 +143,8 @@ async def create_license(
     exist_lic = existing.scalar_one_or_none()
 
     expires_at = datetime.fromisoformat(payload.expires_at)
+    if expires_at.tzinfo is None:
+        expires_at = expires_at.replace(tzinfo=CST)
 
     if exist_lic:
         exist_lic.license_key = payload.license_key
@@ -211,11 +252,11 @@ async def get_license_status(
         raise ValueError("License 不存在")
 
     days_left = _calc_days_left(lic.expires_at)
+    sms_status = None
     if lic.status == "active" and days_left <= 0:
         lic.status = "expired"
         await db.commit()
-        # 触发过期通知
-        await _notify_on_expired(db, lic)
+        sms_status = await _notify_on_expired(db, lic)
 
     sa_result = await db.execute(
         select(SuperAdmin).where(SuperAdmin.id == lic.super_admin_id)
@@ -233,6 +274,7 @@ async def get_license_status(
         max_tenants=lic.max_tenants,
         max_users_per_tenant=lic.max_users_per_tenant,
         remark=lic.remark,
+        sms_status=sms_status,
     )
 
 
@@ -246,9 +288,12 @@ async def revoke_license(
     lic = result.scalar_one_or_none()
     if not lic:
         raise ValueError("License 不存在")
+    old_status = lic.status
     lic.status = "revoked"
     await db.commit()
-    return {"message": "License已吊销"}
+    # 吊销即 valid 变为 false,不管原状态如何都发预警短信
+    sms_status = await _notify_on_expired(db, lic)
+    return {"message": "License已吊销", "sms_status": sms_status}
 
 
 async def update_license(
@@ -262,26 +307,36 @@ async def update_license(
     if not lic:
         raise ValueError("License 不存在")
     changed = False
+    old_status = lic.status
+    sms_status = "none"
     if payload.license_key is not None:
         lic.license_key = payload.license_key
         changed = True
     if payload.expires_at is not None:
-        old_status = lic.status
-        lic.expires_at = datetime.fromisoformat(payload.expires_at)
+        expires_at = datetime.fromisoformat(payload.expires_at)
+        if expires_at.tzinfo is None:
+            expires_at = expires_at.replace(tzinfo=CST)
+        lic.expires_at = expires_at
         if _calc_days_left(lic.expires_at) > 0:
             lic.status = "active"
         else:
             lic.status = "expired"
         changed = True
-        # 状态变化时发送短信通知
-        if old_status == "active" and lic.status == "expired":
-            await _notify_on_expired(db, lic)
-        elif old_status == "expired" and lic.status == "active":
-            await _notify_on_restored(db, lic)
     if not changed:
         raise ValueError("没有需要更新的字段")
+    # 修改过期时间后已超出 7 天窗口,重置预警标记
+    if payload.expires_at is not None and _calc_days_left(lic.expires_at) > 7:
+        lic.warning_sent = False
+    elif old_status != "active" and lic.status == "active":
+        lic.warning_sent = False
     await db.commit()
-    return {"message": "License已更新", "license_id": lic.id}
+
+    # valid 变化时发送短信
+    if old_status == "active" and lic.status != "active":
+        sms_status = await _notify_on_expired(db, lic)
+    elif old_status != "active" and lic.status == "active":
+        sms_status = await _notify_on_restored(db, lic)
+    return {"message": "License已更新", "license_id": lic.id, "sms_status": sms_status}
 
 
 async def restore_license(
@@ -297,10 +352,10 @@ async def restore_license(
     if lic.status != "revoked":
         raise ValueError("仅可恢复已吊销的 License")
     lic.status = "active"
+    lic.warning_sent = False  # 恢复后重置预警标记
     await db.commit()
-    # 触发恢复通知
-    await _notify_on_restored(db, lic)
-    return {"message": "License已恢复"}
+    sms_status = await _notify_on_restored(db, lic)
+    return {"message": "License已恢复", "sms_status": sms_status}
 
 
 async def delete_license(

+ 33 - 16
backend/app/services/sms.py

@@ -16,6 +16,10 @@ logger = logging.getLogger(__name__)
 
 ALIYUN_ENDPOINT = "https://dysmsapi.aliyuncs.com/"
 
+# 内存防重:记录每个手机号的最后一次发送时间,60 秒内不发第二次
+_send_times: dict[str, float] = {}
+_SMS_COOLDOWN = 60  # 秒
+
 
 def _percent_encode(s: str) -> str:
     """RFC 3986 percent encoding"""
@@ -32,12 +36,18 @@ def _build_signature(method: str, params: dict[str, str], secret: str) -> str:
     return base64.b64encode(h.digest()).decode()
 
 
-async def _send_sms(phone: str, template_code: str, template_params: dict) -> bool:
-    """调用阿里云 SendSms 接口"""
+async def _send_sms(phone: str, template_code: str, template_params: dict) -> tuple[bool, str]:
+    """调用阿里云 SendSms 接口。返回 (success, reason)"""
     if not settings.sms_access_key_id or not settings.sms_access_key_secret:
-        logger.warning("阿里云短信配置未设置,跳过短信发送")
-        return False
-
+        logger.warning("阿里云短信配置未设置(AK/SK为空),跳过短信发送")
+        return False, "AK/SK未配置"
+
+    now_ts = datetime.now(timezone.utc).timestamp()
+    last = _send_times.get(phone, 0)
+    if now_ts - last < _SMS_COOLDOWN:
+        logger.info("手机号 %s 距离上次发送不足 %d 秒,跳过防重", phone, _SMS_COOLDOWN)
+        return False, "冷却期内,跳过发送"
+    _send_times[phone] = now_ts
     now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
     params = {
         "AccessKeyId": settings.sms_access_key_id,
@@ -64,30 +74,37 @@ async def _send_sms(phone: str, template_code: str, template_params: dict) -> bo
             result = resp.json()
             if result.get("Code") == "OK":
                 logger.info("短信发送成功: %s", phone)
-                return True
+                return True, "发送成功"
             else:
-                logger.error("短信发送失败: %s - %s", result.get("Code"), result.get("Message"))
-                return False
-    except Exception:
+                code = result.get("Code")
+                msg = result.get("Message")
+                logger.error("短信发送失败: phone=%s, code=%s, msg=%s", phone, code, msg)
+                return False, f"阿里云错误: {code} - {msg}"
+    except Exception as e:
         logger.exception("短信发送异常: %s", phone)
-        return False
+        return False, f"异常: {e}"
 
 
-async def send_sms_code(phone: str, code: str) -> bool:
-    """发送验证码短信"""
+async def send_sms_code(phone: str, code: str) -> tuple[bool, str]:
+    """发送验证码短信。返回 (success, reason)"""
     return await _send_sms(phone, settings.sms_template_code_verify, {"code": code})
 
 
-async def send_license_expired(phone: str, company: str) -> bool:
-    """发送 License 过期通知短信"""
+async def send_license_expired(phone: str, company: str) -> tuple[bool, str]:
+    """发送 License 过期通知短信。返回 (success, reason)"""
     return await _send_sms(phone, settings.sms_template_code_expired, {"company": company})
 
 
-async def send_license_restored(phone: str, company: str) -> bool:
-    """发送 License 恢复通知短信"""
+async def send_license_restored(phone: str, company: str) -> tuple[bool, str]:
+    """发送 License 恢复通知短信。返回 (success, reason)"""
     return await _send_sms(phone, settings.sms_template_code_restored, {"company": company})
 
 
+async def send_license_warning(phone: str, company: str, days: int) -> tuple[bool, str]:
+    """发送 License 即将过期预警短信。返回 (success, reason)"""
+    return await _send_sms(phone, settings.sms_template_code_warning, {"company": company, "days": str(days)})
+
+
 def generate_verify_code() -> str:
     """生成 6 位数字验证码"""
     return "".join(random.choices(string.digits, k=6))

+ 3 - 1
backend/app/services/visitor.py

@@ -79,6 +79,8 @@ async def store_visitor_info(
     code = None
     if payload.phone:
         code = generate_verify_code()
-        await send_sms_code(payload.phone, code)
+        logger.info("发送验证码短信: phone=%s", payload.phone)
+        ok, reason = await send_sms_code(payload.phone, code)
+        logger.info("验证码短信发送结果: ok=%s, reason=%s", ok, reason)
 
     return {"success": True, "message": "联系人信息已保存", "domain": domain.domain, "code": code}

+ 4 - 0
backend/migrations/012_add_warning_sent.sql

@@ -0,0 +1,4 @@
+-- 为 super_admin_license 表添加 warning_sent 字段
+-- 用于标记 7 天预警短信是否已发送,避免重复发送
+ALTER TABLE domain_monitor.super_admin_license
+ADD COLUMN IF NOT EXISTS warning_sent BOOLEAN DEFAULT FALSE;

+ 11 - 3
frontend/src/App.tsx

@@ -763,9 +763,17 @@ function LicensePage() {
     onError: (err: any) => { showToast(err.response?.data?.detail || "创建失败", "error"); },
   });
 
+  // 短信状态提示 — 根据操作类型显示不同文案
+  const showSmsToast = (status: string, action: "expired" | "restored" = "restored") => {
+    const label = action === "expired" ? "预警" : "恢复";
+    if (status === "sent") showToast(`${label}短信已发送成功`, "success");
+    else if (status === "skipped") showToast(`未找到联系人手机号,${label}短信未发送`, "error");
+    else if (status === "failed") showToast(`业务操作成功,但${label}短信发送失败,请查看日志`, "error");
+  };
+
   const revokeMutation = useMutation({
     mutationFn: (id: number) => licenseApi.revoke(id),
-    onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); showToast("License 已吊销", "success"); },
+    onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); showToast("License 已吊销", "success"); showSmsToast(res.data?.sms_status, "expired"); },
     onError: () => { showToast("吊销失败", "error"); },
   });
 
@@ -777,14 +785,14 @@ function LicensePage() {
 
   const restoreMutation = useMutation({
     mutationFn: (id: number) => licenseApi.restore(id),
-    onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); showToast("License 已恢复", "success"); },
+    onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); showToast("License 已恢复", "success"); showSmsToast(res.data?.sms_status, "restored"); },
     onError: () => { showToast("恢复失败", "error"); },
   });
 
   const updateMutation = useMutation({
     mutationFn: ({ id, data }: { id: number; data: { license_key?: string; expires_at?: string } }) =>
       licenseApi.update(id, data),
-    onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); setEditTarget(null); showToast("License 已更新", "success"); },
+    onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); setEditTarget(null); showToast("License 已更新", "success"); showSmsToast(res.data?.sms_status); },
     onError: () => { showToast("更新失败", "error"); },
   });