Ver código fonte

新增license预警功能

lxylxy123321 2 semanas atrás
pai
commit
126ef3edce
28 arquivos alterados com 528 adições e 9 exclusões
  1. 5 3
      .gitignore
  2. 10 0
      backend/app/config.py
  3. BIN
      backend/app/models/__pycache__/__init__.cpython-311.pyc
  4. BIN
      backend/app/models/__pycache__/domain.cpython-311.pyc
  5. BIN
      backend/app/models/__pycache__/license.cpython-311.pyc
  6. BIN
      backend/app/models/__pycache__/monitoring.cpython-311.pyc
  7. 18 0
      backend/app/models/visitor.py
  8. BIN
      backend/app/routers/__pycache__/__init__.cpython-311.pyc
  9. BIN
      backend/app/routers/__pycache__/domains.cpython-311.pyc
  10. BIN
      backend/app/routers/__pycache__/license.cpython-311.pyc
  11. BIN
      backend/app/routers/__pycache__/monitoring.cpython-311.pyc
  12. 12 0
      backend/app/routers/license.py
  13. BIN
      backend/app/schemas/__pycache__/__init__.cpython-311.pyc
  14. BIN
      backend/app/schemas/__pycache__/domain.cpython-311.pyc
  15. BIN
      backend/app/schemas/__pycache__/license.cpython-311.pyc
  16. BIN
      backend/app/schemas/__pycache__/monitoring.cpython-311.pyc
  17. 1 0
      backend/app/schemas/license.py
  18. 20 0
      backend/app/schemas/visitor.py
  19. BIN
      backend/app/services/__pycache__/__init__.cpython-311.pyc
  20. BIN
      backend/app/services/__pycache__/domain_fetch.cpython-311.pyc
  21. BIN
      backend/app/services/__pycache__/license.cpython-311.pyc
  22. BIN
      backend/app/services/__pycache__/monitoring.cpython-311.pyc
  23. 80 3
      backend/app/services/license.py
  24. 93 0
      backend/app/services/sms.py
  25. 84 0
      backend/app/services/visitor.py
  26. 11 0
      backend/migrations/011_visitor_info.sql
  27. 11 3
      frontend/src/App.tsx
  28. 183 0
      visitor_api.md

+ 5 - 3
.gitignore

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

+ 10 - 0
backend/app/config.py

@@ -20,6 +20,16 @@ class Settings(BaseSettings):
     domain_api_base_url: str = ""
     domain_api_key: str = ""
 
+    # 阿里云短信配置
+    sms_access_key_id: str = ""
+    sms_access_key_secret: str = ""
+    sms_sign_name: str = "四川网讯创智数字产业发展"
+
+    # 短信模板 CODE
+    sms_template_code_verify: str = "SMS_333915522"
+    sms_template_code_expired: str = "SMS_506350367"
+    sms_template_code_restored: str = "SMS_506275397"
+
     @property
     def database_url(self) -> str:
         """拼接 SQLAlchemy 异步连接字符串"""

BIN
backend/app/models/__pycache__/__init__.cpython-311.pyc


BIN
backend/app/models/__pycache__/domain.cpython-311.pyc


BIN
backend/app/models/__pycache__/license.cpython-311.pyc


BIN
backend/app/models/__pycache__/monitoring.cpython-311.pyc


+ 18 - 0
backend/app/models/visitor.py

@@ -0,0 +1,18 @@
+from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, func
+from sqlalchemy.orm import declarative_base
+
+Base = declarative_base()
+
+
+class VisitorInfo(Base):
+    """域名联系人信息表"""
+    __tablename__ = "visitor_info"
+    __table_args__ = {"schema": "domain_monitor"}
+
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    domain_id = Column(Integer, ForeignKey("domain_monitor.monitored_domains.id", ondelete="CASCADE"), nullable=False, unique=True)
+    name = Column(String(100))
+    phone = Column(String(50))
+    email = Column(String(200))
+    created_at = Column(DateTime(timezone=True), server_default=func.now())
+    updated_at = Column(DateTime(timezone=True), onupdate=func.now())

BIN
backend/app/routers/__pycache__/__init__.cpython-311.pyc


BIN
backend/app/routers/__pycache__/domains.cpython-311.pyc


BIN
backend/app/routers/__pycache__/license.cpython-311.pyc


BIN
backend/app/routers/__pycache__/monitoring.cpython-311.pyc


+ 12 - 0
backend/app/routers/license.py

@@ -13,6 +13,8 @@ from app.services.license import (
     check_license_by_referer,
 )
 from app.schemas.license import LicenseCreate, LicenseUpdate
+from app.schemas.visitor import VisitorInfoCreate
+from app.services.visitor import store_visitor_info
 
 router = APIRouter(prefix="/api/license", tags=["License许可"])
 
@@ -102,6 +104,16 @@ async def handle_delete_license(
         raise HTTPException(status_code=404, detail=str(e))
 
 
+@public_router.post("/visitor", summary="公开接口:存储域名联系人信息")
+async def handle_store_visitor(
+    payload: VisitorInfoCreate,
+    referer: str | None = Header(None),
+    db: AsyncSession = Depends(get_db),
+):
+    """第三方系统通过请求携带的 Referer 头匹配域名,存储联系人信息(名字/手机/邮箱)。"""
+    return await store_visitor_info(db, referer, payload)
+
+
 @public_router.get("/license/check", summary="公开 License 校验接口(通过 Referer 匹配域名)")
 async def handle_license_check(
     referer: str | None = Header(None),

BIN
backend/app/schemas/__pycache__/__init__.cpython-311.pyc


BIN
backend/app/schemas/__pycache__/domain.cpython-311.pyc


BIN
backend/app/schemas/__pycache__/license.cpython-311.pyc


BIN
backend/app/schemas/__pycache__/monitoring.cpython-311.pyc


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

@@ -27,6 +27,7 @@ class LicenseResponse(BaseModel):
     created_at: Optional[str] = None
     updated_at: Optional[str] = None
     domain: Optional[str] = None
+    contact: Optional[dict] = None
 
 
 class LicenseStatusResponse(BaseModel):

+ 20 - 0
backend/app/schemas/visitor.py

@@ -0,0 +1,20 @@
+from pydantic import BaseModel
+from typing import Optional
+
+
+class VisitorInfoCreate(BaseModel):
+    """访客信息创建请求"""
+    name: Optional[str] = None
+    phone: Optional[str] = None
+    email: Optional[str] = None
+
+
+class VisitorInfoResponse(BaseModel):
+    """访客信息响应"""
+    id: int
+    domain_id: int
+    name: Optional[str] = None
+    phone: Optional[str] = None
+    email: Optional[str] = None
+
+    model_config = {"from_attributes": True}

BIN
backend/app/services/__pycache__/__init__.cpython-311.pyc


BIN
backend/app/services/__pycache__/domain_fetch.cpython-311.pyc


BIN
backend/app/services/__pycache__/license.cpython-311.pyc


BIN
backend/app/services/__pycache__/monitoring.cpython-311.pyc


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

@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from app.models.license import SuperAdminLicense
 from app.models.monitoring import SuperAdmin
 from app.models.domain import MonitoredDomain
+from app.models.visitor import VisitorInfo
 from app.schemas.license import (
     LicenseCreate,
     LicenseResponse,
@@ -15,6 +16,10 @@ from app.schemas.license import (
     SuperAdminOption,
     LicenseListResponse,
 )
+from app.services.sms import send_license_expired, send_license_restored
+import logging
+
+logger = logging.getLogger(__name__)
 
 
 def _to_str(val) -> str:
@@ -25,6 +30,54 @@ def _to_str(val) -> str:
     return str(val)
 
 
+async def _get_contact_for_license(db: AsyncSession, lic: SuperAdminLicense) -> tuple[str | None, str | None]:
+    """根据 License 获取关联的联系人 phone 和 company 名称。返回 (phone, company)"""
+    domain_result = await db.execute(
+        select(MonitoredDomain).where(
+            MonitoredDomain.super_admin_id == lic.super_admin_id,
+            MonitoredDomain.is_active == True,
+        ).limit(1)
+    )
+    domain = domain_result.scalar_one_or_none()
+    if not domain:
+        return None, None
+
+    sa_result = await db.execute(
+        select(SuperAdmin).where(SuperAdmin.id == lic.super_admin_id)
+    )
+    sa = sa_result.scalar_one_or_none()
+    company = sa.remark or sa.username if sa else str(lic.super_admin_id)
+
+    visitor_result = await db.execute(
+        select(VisitorInfo).where(VisitorInfo.domain_id == domain.id)
+    )
+    visitor = visitor_result.scalar_one_or_none()
+    if not visitor or not visitor.phone:
+        return None, company
+
+    return visitor.phone, company
+
+
+async def _notify_on_expired(db: AsyncSession, lic: SuperAdminLicense):
+    """License 过期时发送短信通知(静默失败,不阻断主流程)"""
+    try:
+        phone, company = await _get_contact_for_license(db, lic)
+        if phone:
+            await send_license_expired(phone, company)
+    except Exception:
+        logger.exception("发送过期短信失败,license_id=%d", lic.id)
+
+
+async def _notify_on_restored(db: AsyncSession, lic: SuperAdminLicense):
+    """License 恢复时发送短信通知(静默失败,不阻断主流程)"""
+    try:
+        phone, company = await _get_contact_for_license(db, lic)
+        if phone:
+            await send_license_restored(phone, company)
+    except Exception:
+        logger.exception("发送恢复短信失败,license_id=%d", lic.id)
+
+
 def _calc_days_left(expires_at: datetime) -> int:
     now = datetime.now(timezone.utc) if expires_at.tzinfo else datetime.now()
     delta = expires_at - now
@@ -109,12 +162,23 @@ async def list_licenses(
 
         # 查询关联的域名
         domain_result = await db.execute(
-            select(MonitoredDomain.domain).where(
+            select(MonitoredDomain).where(
                 MonitoredDomain.super_admin_id == r.super_admin_id,
                 MonitoredDomain.is_active == True,
             ).limit(1)
         )
-        domain_name = domain_result.scalar_one_or_none()
+        domain_row = domain_result.scalar_one_or_none()
+        domain_name = domain_row.domain if domain_row else None
+
+        # 查询关联的联系人信息
+        contact = None
+        if domain_row:
+            visitor_result = await db.execute(
+                select(VisitorInfo).where(VisitorInfo.domain_id == domain_row.id)
+            )
+            visitor = visitor_result.scalar_one_or_none()
+            if visitor:
+                contact = {"name": visitor.name, "phone": visitor.phone, "email": visitor.email}
 
         items.append(LicenseResponse(
             id=r.id,
@@ -129,6 +193,7 @@ async def list_licenses(
             created_at=_to_str(r.created_at),
             updated_at=_to_str(r.updated_at),
             domain=domain_name,
+            contact=contact,
         ))
 
     return LicenseListResponse(total=total, items=items)
@@ -146,9 +211,11 @@ async def get_license_status(
         raise ValueError("License 不存在")
 
     days_left = _calc_days_left(lic.expires_at)
-    if days_left <= 0:
+    if lic.status == "active" and days_left <= 0:
         lic.status = "expired"
         await db.commit()
+        # 触发过期通知
+        await _notify_on_expired(db, lic)
 
     sa_result = await db.execute(
         select(SuperAdmin).where(SuperAdmin.id == lic.super_admin_id)
@@ -199,12 +266,18 @@ async def update_license(
         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)
         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("没有需要更新的字段")
     await db.commit()
@@ -225,6 +298,8 @@ async def restore_license(
         raise ValueError("仅可恢复已吊销的 License")
     lic.status = "active"
     await db.commit()
+    # 触发恢复通知
+    await _notify_on_restored(db, lic)
     return {"message": "License已恢复"}
 
 
@@ -323,6 +398,8 @@ async def check_license_by_referer(
     if lic.status == "active" and days_left <= 0:
         lic.status = "expired"
         await db.commit()
+        # 触发过期通知
+        await _notify_on_expired(db, lic)
 
     sa_result = await db.execute(
         select(SuperAdmin).where(SuperAdmin.id == lic.super_admin_id)

+ 93 - 0
backend/app/services/sms.py

@@ -0,0 +1,93 @@
+"""阿里云短信服务封装"""
+import base64
+import hashlib
+import hmac
+import logging
+import random
+import string
+import uuid
+from datetime import datetime, timezone
+from urllib.parse import quote
+
+import httpx
+from app.config import settings
+
+logger = logging.getLogger(__name__)
+
+ALIYUN_ENDPOINT = "https://dysmsapi.aliyuncs.com/"
+
+
+def _percent_encode(s: str) -> str:
+    """RFC 3986 percent encoding"""
+    return quote(s, safe="").replace("+", "%20").replace("*", "%2A").replace("%7E", "~")
+
+
+def _build_signature(method: str, params: dict[str, str], secret: str) -> str:
+    """阿里云 POP 签名"""
+    sorted_keys = sorted(params.keys())
+    canonicalized = "&".join(f"{_percent_encode(k)}={_percent_encode(params[k])}" for k in sorted_keys)
+    string_to_sign = f"{method.upper()}&%2F&{_percent_encode(canonicalized)}"
+    signing_key = secret + "&"
+    h = hmac.new(signing_key.encode(), string_to_sign.encode(), hashlib.sha1)
+    return base64.b64encode(h.digest()).decode()
+
+
+async def _send_sms(phone: str, template_code: str, template_params: dict) -> bool:
+    """调用阿里云 SendSms 接口"""
+    if not settings.sms_access_key_id or not settings.sms_access_key_secret:
+        logger.warning("阿里云短信配置未设置,跳过短信发送")
+        return False
+
+    now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+    params = {
+        "AccessKeyId": settings.sms_access_key_id,
+        "Action": "SendSms",
+        "Format": "JSON",
+        "PhoneNumbers": phone,
+        "RegionId": "cn-hangzhou",
+        "SignName": settings.sms_sign_name,
+        "SignatureMethod": "HMAC-SHA1",
+        "SignatureNonce": uuid.uuid4().hex,
+        "SignatureVersion": "1.0",
+        "TemplateCode": template_code,
+        "TemplateParam": str(template_params).replace("'", '"'),
+        "Timestamp": now,
+        "Version": "2017-05-25",
+    }
+
+    signature = _build_signature("POST", params, settings.sms_access_key_secret)
+    params["Signature"] = signature
+
+    try:
+        async with httpx.AsyncClient(timeout=10) as client:
+            resp = await client.post(ALIYUN_ENDPOINT, data=params)
+            result = resp.json()
+            if result.get("Code") == "OK":
+                logger.info("短信发送成功: %s", phone)
+                return True
+            else:
+                logger.error("短信发送失败: %s - %s", result.get("Code"), result.get("Message"))
+                return False
+    except Exception:
+        logger.exception("短信发送异常: %s", phone)
+        return False
+
+
+async def send_sms_code(phone: str, code: str) -> bool:
+    """发送验证码短信"""
+    return await _send_sms(phone, settings.sms_template_code_verify, {"code": code})
+
+
+async def send_license_expired(phone: str, company: str) -> bool:
+    """发送 License 过期通知短信"""
+    return await _send_sms(phone, settings.sms_template_code_expired, {"company": company})
+
+
+async def send_license_restored(phone: str, company: str) -> bool:
+    """发送 License 恢复通知短信"""
+    return await _send_sms(phone, settings.sms_template_code_restored, {"company": company})
+
+
+def generate_verify_code() -> str:
+    """生成 6 位数字验证码"""
+    return "".join(random.choices(string.digits, k=6))

+ 84 - 0
backend/app/services/visitor.py

@@ -0,0 +1,84 @@
+from urllib.parse import urlparse
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from app.models.visitor import VisitorInfo
+from app.models.domain import MonitoredDomain
+from app.schemas.visitor import VisitorInfoCreate
+from app.services.sms import send_sms_code, generate_verify_code
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def _extract_domain_from_referer(referer: str | None) -> str | None:
+    """从 Referer 头提取域名"""
+    if not referer:
+        return None
+    try:
+        parsed = urlparse(referer)
+        host = parsed.hostname
+        if not host:
+            host = referer.strip().rstrip("/")
+        if not host:
+            return None
+        netloc = parsed.netloc if parsed.netloc else host
+        return host, netloc
+    except Exception:
+        return None
+
+
+async def store_visitor_info(
+    db: AsyncSession, referer: str | None, payload: VisitorInfoCreate
+) -> dict:
+    """通过 Referer 匹配域名,存储联系人信息。同一域名覆盖更新。"""
+    result = _extract_domain_from_referer(referer)
+    if not result:
+        return {"success": False, "message": "缺少或无效的 Referer 头"}
+
+    referer_host, referer_netloc = result
+
+    # 匹配监控域名(支持多种存储格式)
+    domain_result = await db.execute(
+        select(MonitoredDomain).where(
+            MonitoredDomain.is_active == True,
+            MonitoredDomain.domain.in_([
+                referer_netloc,
+                referer_host,
+                f"http://{referer_netloc}",
+                f"https://{referer_netloc}",
+            ]),
+        )
+    )
+    domain = domain_result.scalars().first()
+    if not domain:
+        return {"success": False, "message": "未找到匹配的域名"}
+
+    # Upsert 联系人信息
+    existing = await db.execute(
+        select(VisitorInfo).where(VisitorInfo.domain_id == domain.id)
+    )
+    visitor = existing.scalar_one_or_none()
+
+    if visitor:
+        visitor.name = payload.name
+        visitor.phone = payload.phone
+        visitor.email = payload.email
+    else:
+        visitor = VisitorInfo(
+            domain_id=domain.id,
+            name=payload.name,
+            phone=payload.phone,
+            email=payload.email,
+        )
+        db.add(visitor)
+
+    await db.commit()
+
+    # 发送验证码短信,并将验证码返回给请求者
+    code = None
+    if payload.phone:
+        code = generate_verify_code()
+        await send_sms_code(payload.phone, code)
+
+    return {"success": True, "message": "联系人信息已保存", "domain": domain.domain, "code": code}

+ 11 - 0
backend/migrations/011_visitor_info.sql

@@ -0,0 +1,11 @@
+-- 新建 visitor_info 表,存储域名联系人信息
+CREATE TABLE IF NOT EXISTS domain_monitor.visitor_info (
+    id SERIAL PRIMARY KEY,
+    domain_id INT NOT NULL UNIQUE,
+    name VARCHAR(100),
+    phone VARCHAR(50),
+    email VARCHAR(200),
+    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+    updated_at TIMESTAMP WITH TIME ZONE,
+    CONSTRAINT fk_visitor_domain FOREIGN KEY (domain_id) REFERENCES domain_monitor.monitored_domains(id) ON DELETE CASCADE
+);

+ 11 - 3
frontend/src/App.tsx

@@ -877,7 +877,7 @@ function LicensePage() {
         <Card title={`License 列表(共 ${licenses?.total ?? 0} 条)`}>
           {!licenses?.items?.length ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>暂无 License 记录</div> : (
             <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
-              <thead><tr>{["超管", "关联域名", "License Key", "过期时间", "状态", "剩余天数", "最大租户", "备注", "操作"].map((h) => <th key={h} style={{ padding: "8px 10px", textAlign: "left", fontSize: 11, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
+              <thead><tr>{["超管", "关联域名", "联系人", "License Key", "过期时间", "状态", "剩余天数", "备注", "操作"].map((h) => <th key={h} style={{ padding: "8px 10px", textAlign: "left", fontSize: 11, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
               <tbody>
                 {licenses.items.map((l: any) => {
                   const sc = statusColor(l.status);
@@ -886,13 +886,21 @@ function LicensePage() {
                     <tr key={l.id} style={{ borderBottom: `1px solid ${T.border}` }}>
                       <td style={{ padding: "8px 10px" }}>{l.super_admin_name || l.super_admin_id}</td>
                       <td style={{ padding: "8px 10px", fontFamily: "monospace", fontSize: 12, color: T.textSec }}>{l.domain || "-"}</td>
+                      <td style={{ padding: "8px 10px", fontSize: 12 }}>
+                        {l.contact?.name || l.contact?.phone || l.contact?.email ? (
+                          <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
+                            {l.contact.name && <span style={{ fontWeight: 500 }}>{l.contact.name}</span>}
+                            {l.contact.phone && <span style={{ color: T.textSec }}>{l.contact.phone}</span>}
+                            {l.contact.email && <span style={{ color: T.textSec }}>{l.contact.email}</span>}
+                          </div>
+                        ) : "-"}
+                      </td>
                       <td style={{ padding: "8px 10px", fontFamily: "monospace", fontWeight: 500, fontSize: 12 }}>{l.license_key}</td>
-                      <td style={{ padding: "8px 10px", fontSize: 12 }}>{l.expires_at ? l.expires_at.split(".")[0]?.replace("T", " ") : "-"}</td>
+                      <td style={{ padding: "8px 10px", fontSize: 12 }}>{l.expires_at ? (l.expires_at.replace("T", " ").split("+")[0] || "-") : "-"}</td>
                       <td style={{ padding: "8px 10px" }}>
                         <span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 8px", borderRadius: 99, fontSize: 12, background: sc.bg, color: sc.color }}>{sc.text}</span>
                       </td>
                       <td style={{ padding: "8px 10px", color: daysLeft <= 7 && l.status === "active" ? T.danger : T.text }}>{l.status === "active" ? `${daysLeft} 天` : "-"}</td>
-                      <td style={{ padding: "8px 10px", color: T.textSec }}>{l.max_tenants ?? "-"}</td>
                       <td style={{ padding: "8px 10px", color: T.textSec, maxWidth: 150, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={l.remark || ""}>{l.remark || "-"}</td>
                       <td style={{ padding: "8px 10px", display: "flex", gap: 6 }}>
                         <button onClick={() => setEditTarget(l)} style={{ padding: "4px 8px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.primary}`, background: "transparent", color: T.primary }}>编辑</button>

+ 183 - 0
visitor_api.md

@@ -0,0 +1,183 @@
+# Visitor 联系人信息上报 API 文档
+
+## 1. 概述
+
+Visitor 接口用于第三方系统向本平台上报联系人信息(姓名、手机、邮箱)。平台通过请求的 `Referer` 头自动匹配对应监控域名,将联系人信息存储到该域名下,并向传入的手机号发送短信验证码。
+
+**基础 URL**:`http://<host>:<port>`(开发环境默认 `http://localhost:8000`)
+
+---
+
+## 2. 接口详情
+
+### 2.1 上报联系人信息
+
+```
+POST /api/public/visitor
+Content-Type: application/json
+```
+
+**请求头**:
+
+| 头 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `Referer` | string | 是 | 请求来源页面的完整 URL,用于提取域名匹配 |
+
+**请求体**:
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `name` | string | 否 | 联系人姓名 |
+| `phone` | string | 否 | 联系电话(传入后会收到验证码短信) |
+| `email` | string | 否 | 电子邮箱 |
+
+**处理流程**:
+
+```
+POST /api/public/visitor
+  → 从 Referer 头提取域名
+  → 在 monitored_domains 表中匹配 domain(需 is_active=true)
+  → 获取该记录的 domain_id
+  → 在 visitor_info 表中 upsert 联系人信息(同 domain_id 则更新,否则新建)
+  → 生成 6 位数字验证码,通过阿里云短信发送至传入的 phone
+  → 返回保存结果及验证码
+```
+
+**curl 示例**:
+
+```bash
+curl -X POST "http://localhost:8000/api/public/visitor" \
+  -H "Content-Type: application/json" \
+  -H "Referer: https://example.com/dashboard" \
+  -d '{
+    "name": "张三",
+    "phone": "13800138000",
+    "email": "zhangsan@example.com"
+  }'
+```
+
+### 2.2 响应
+
+#### 保存成功
+
+```json
+{
+  "success": true,
+  "message": "联系人信息已保存",
+  "domain": "example.com",
+  "code": "843291"
+}
+```
+
+> 返回的 `code` 为 6 位数字验证码。如未传入 `phone` 字段,则 `code` 为 `null`。
+
+#### 缺少或无效 Referer
+
+```json
+{
+  "success": false,
+  "message": "缺少或无效的 Referer 头"
+}
+```
+
+#### 未找到匹配的域名
+
+```json
+{
+  "success": false,
+  "message": "未找到匹配的域名"
+}
+```
+
+### 2.3 调用示例
+
+**前端(浏览器自动携带 Referer)**:
+
+```javascript
+const res = await fetch('/api/public/visitor', {
+  method: 'POST',
+  headers: { 'Content-Type': 'application/json' },
+  body: JSON.stringify({
+    name: '张三',
+    phone: '13800138000',
+    email: 'zhangsan@example.com',
+  }),
+});
+const data = await res.json();
+console.log(`验证码: ${data.code}`);
+```
+
+**Python 后端**:
+
+```python
+import requests
+
+resp = requests.post(
+    "https://license-server.example.com/api/public/visitor",
+    headers={"Referer": "https://example.com/dashboard"},
+    json={
+        "name": "张三",
+        "phone": "13800138000",
+        "email": "zhangsan@example.com",
+    },
+)
+print(resp.json())
+```
+
+**Node.js**:
+
+```javascript
+const resp = await fetch("https://license-server.example.com/api/public/visitor", {
+  method: "POST",
+  headers: { "Referer": "https://example.com/dashboard", "Content-Type": "application/json" },
+  body: JSON.stringify({ name: "张三", phone: "13800138000", email: "zhangsan@example.com" }),
+});
+console.log(await resp.json());
+```
+
+**curl**:
+
+```bash
+curl -X POST \
+  -H "Referer: https://example.com/dashboard" \
+  -H "Content-Type: application/json" \
+  -d '{"name":"张三","phone":"13800138000","email":"zhangsan@example.com"}' \
+  "https://license-server.example.com/api/public/visitor"
+```
+
+---
+
+## 3. 数据表
+
+### visitor_info
+
+联系人信息表,与 `monitored_domains` 一对一关联。同一域名多次请求会覆盖更新已有记录。
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | SERIAL PK | 主键 |
+| `domain_id` | INT (UNIQUE) | 关联的域名 ID |
+| `name` | VARCHAR(100) | 联系人姓名 |
+| `phone` | VARCHAR(50) | 联系电话 |
+| `email` | VARCHAR(200) | 电子邮箱 |
+| `created_at` | TIMESTAMPTZ | 创建时间 |
+| `updated_at` | TIMESTAMPTZ | 更新时间 |
+
+---
+
+## 4. 常见问题
+
+**Q: 同一域名多次请求会怎样?**
+A: 覆盖更新已有联系人信息,不会新增记录。`domain_id` 有唯一约束保证不会重复。
+
+**Q: 需要认证吗?**
+A: 不需要。`POST /api/public/visitor` 是公开接口,第三方系统可直接调用。
+
+**Q: 不传手机号会发短信吗?**
+A: 不会。仅当 `phone` 字段有值时才发送短信验证码,但联系人信息仍会正常保存。
+
+**Q: 验证码是什么用途?**
+A: 验证码通过短信发送到联系人手机,同时在响应中返回给调用方,方便第三方系统做二次校验。
+
+**Q: 域名匹配支持哪些格式?**
+A: 支持纯域名(`example.com`)、带端口(`example.com:8080`)、带协议(`http://example.com` / `https://example.com`)。